diff --git a/.circleci/config.yml b/.circleci/config.yml index 8320e348..37823cbd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,7 +4,7 @@ version: 2.1 orbs: - codecov: codecov/codecov@3.2.5 + # codecov: codecov/codecov@3.3.0 macos: circleci/macos@2 workflows: @@ -40,38 +40,26 @@ commands: paths: - vendor/bundle - # restore pods related caches - - restore_cache: - name: Restore CocoaPods Cache - keys: - - cocoapods-cache-v5-{{ arch }}-{{ .Branch }}-{{ checksum "Podfile.lock" }} - - cocoapods-cache-v5-{{ arch }}-{{ .Branch }} - - cocoapods-cache-v5 - # install CocoaPods - using default CocoaPods version, not the bundle - run: name: Repo Update & Install CocoaPods command: make ci-pod-install - # save pods related files - - save_cache: - name: Save CocoaPods Cache - key: cocoapods-cache-v5-{{ arch }}-{{ .Branch }}-{{ checksum "Podfile.lock" }} - paths: - - ./Pods - - ~/.cocoapods + - run: + name: Install xcodegen + command: brew install xcodegen prestart_ios_simulator: steps: - macos/preboot-simulator: platform: "iOS" - version: "16.1" - device: "iPhone 14" + version: "17.2" + device: "iPhone 15" jobs: validate-code: macos: - xcode: 14.1.0 # Specify the Xcode version to use + xcode: 15.1.0 # Specify the Xcode version to use steps: - checkout @@ -88,7 +76,7 @@ jobs: test-ios: macos: - xcode: 14.1.0 # Specify the Xcode version to use + xcode: 15.1.0 # Specify the Xcode version to use steps: - checkout @@ -98,40 +86,47 @@ jobs: - prestart_ios_simulator - run: - name: Run AEPMessaging Tests - command: make test + name: Run Unit Tests + command: make unit-test # Code coverage upload using Codecov # See options explanation here: https://docs.codecov.com/docs/codecov-uploader - - codecov/upload: - flags: aepmessaging-ios-tests - upload_name: Coverage Report for AEPMessaging iOS Tests - xtra_args: -c -v --xc --xp ./build/AEPMessaging.xcresult + # - codecov/upload: + # flags: aepmessaging-ios-tests + # upload_name: Coverage Report for AEPMessaging iOS Tests + # xtra_args: -c -v --xc --xp ./build/AEPMessaging.xcresult + + - run: + name: Run Functional Tests + command: make functional-test + when: always # run even if unit tests fail test-spm-podspec-archive: macos: - xcode: 14.1.0 # Specify the Xcode version to use + xcode: 15.0 # Specify the Xcode version to use steps: - checkout + - install_dependencies + # verify XCFramework archive builds - run: name: Build XCFramework command: | - if [ "${CIRCLE_BRANCH}" == "main" ]; then - make archive + if [ "${CIRCLE_BRANCH}" == "main" ] || [ "${CIRCLE_BRANCH}" == "staging" ]; then + make ci-archive fi # verify podspec is valid - - run: - name: Test Podspec - command: | - if [ "${CIRCLE_BRANCH}" == "main" ]; then - make test-podspec - fi + # - run: + # name: Test Podspec + # command: | + # if [ "${CIRCLE_BRANCH}" == "main" ]; then + # make test-podspec + # fi # verify SPM works - run: name: Test SPM command: | - if [ "${CIRCLE_BRANCH}" == "main" ]; then + if [ "${CIRCLE_BRANCH}" == "main" ] || [ "${CIRCLE_BRANCH}" == "staging" ]; then make test-SPM-integration - fi \ No newline at end of file + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32942c9c..6dea75bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: tag: description: 'tag/version' required: true - default: '4.0.0' + default: '5.0.0' action_tag: description: 'create tag? ("no" to skip)' @@ -20,20 +20,23 @@ on: jobs: release_messaging: - runs-on: macos-latest + runs-on: macos-13 steps: - uses: actions/checkout@v2 with: ref: main - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '14.1' + xcode-version: '15.0.1' - name: Install jq run: brew install jq - name: Install cocoapods run: gem install cocoapods + + - name: Install xcodegen + run: brew install xcodegen - name: Check version in Podspec run: | @@ -92,6 +95,6 @@ jobs: if: ${{ github.event.inputs.release_AEPMessaging == 'yes' }} run: | set -eo pipefail - pod trunk push AEPMessaging.podspec --allow-warnings --synchronous --swift-version=5.1 + pod trunk push AEPMessaging.podspec --allow-warnings --synchronous env: COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} diff --git a/.swiftformat b/.swiftformat index b75dd883..6fabd00e 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,2 +1,3 @@ # For details on formatting rules, refer https://github.com/nicklockwood/SwiftFormat/blob/master/Rules.md ---commas inline \ No newline at end of file +--commas inline +--disable wrapMultilineStatementBraces \ No newline at end of file diff --git a/AEPMessaging.podspec b/AEPMessaging.podspec index a4f73a6b..94b34c6f 100644 --- a/AEPMessaging.podspec +++ b/AEPMessaging.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "AEPMessaging" - s.version = "4.1.1" + s.version = "5.0.0" s.summary = "Messaging extension for Adobe Experience Cloud SDK. Written and maintained by Adobe." s.description = <<-DESC The Messaging extension is used in conjunction with Adobe Journey Optimizer and Adobe Experience Platform to deliver in-app and push messages. @@ -11,14 +11,14 @@ Pod::Spec.new do |s| s.author = "Adobe Experience Platform Messaging SDK Team" s.source = { :git => 'https://github.com/adobe/aepsdk-messaging-ios.git', :tag => s.version.to_s } - s.platform = :ios, "11.0" + s.platform = :ios, "12.0" s.swift_version = '5.1' s.pod_target_xcconfig = { 'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES' } - s.dependency 'AEPCore', '>= 4.0.0', '< 5.0.0' - s.dependency 'AEPServices', '>= 4.0.0', '< 5.0.0' - s.dependency 'AEPEdge', '>= 4.0.0', '< 5.0.0' - s.dependency 'AEPEdgeIdentity', '>= 4.0.0', '< 5.0.0' + s.dependency 'AEPCore', '>= 5.0.0', '< 6.0.0' + s.dependency 'AEPServices', '>= 5.0.0', '< 6.0.0' + s.dependency 'AEPEdge', '>= 5.0.0', '< 6.0.0' + s.dependency 'AEPEdgeIdentity', '>= 5.0.0', '< 6.0.0' s.source_files = 'AEPMessaging/Sources/**/*.swift' diff --git a/AEPMessaging.xcodeproj/project.pbxproj b/AEPMessaging.xcodeproj/project.pbxproj index 8390d306..4ef16e78 100644 --- a/AEPMessaging.xcodeproj/project.pbxproj +++ b/AEPMessaging.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -22,10 +22,49 @@ /* Begin PBXBuildFile section */ 01B9DD6617C6AD6E99EE8472 /* Pods_E2EFunctionalTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB180620A608D2821FE98F37 /* Pods_E2EFunctionalTests.framework */; }; + 090290C329D4EA9F00388226 /* MockCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2469A5E8274C107100E56457 /* MockCache.swift */; }; + 090290C529DCED0B00388226 /* FeedRulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090290C429DCED0B00388226 /* FeedRulesEngine.swift */; }; + 090290C929DD11EA00388226 /* RuleConsequence+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090290C829DD11EA00388226 /* RuleConsequence+Messaging.swift */; }; + 090290CB29DE3F8200388226 /* MockFeedRulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090290CA29DE3F8200388226 /* MockFeedRulesEngine.swift */; }; + 091881E72A16BAE300615481 /* MessagingDemoAppSwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091881E62A16BAE300615481 /* MessagingDemoAppSwiftUIApp.swift */; }; + 091881E92A16BAE300615481 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091881E82A16BAE300615481 /* HomeView.swift */; }; + 091881EB2A16BAE400615481 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 091881EA2A16BAE400615481 /* Assets.xcassets */; }; + 091881F42A16C2A200615481 /* InAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091881F22A16C2A200615481 /* InAppView.swift */; }; + 091881F72A16C2D600615481 /* FeedsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091881F52A16C2D600615481 /* FeedsView.swift */; }; + 091881FB2A16D15100615481 /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; + 091881FC2A16D15100615481 /* AEPMessaging.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 091881FF2A16D7A200615481 /* PushView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091881FE2A16D7A200615481 /* PushView.swift */; }; + 09265D602B74497E0085D825 /* codeBasedPropositionHtml.json in Resources */ = {isa = PBXBuildFile; fileRef = 09265D5C2B74497E0085D825 /* codeBasedPropositionHtml.json */; }; + 09265D612B74497E0085D825 /* codeBasedPropositionHtmlContent.json in Resources */ = {isa = PBXBuildFile; fileRef = 09265D5D2B74497E0085D825 /* codeBasedPropositionHtmlContent.json */; }; + 09265D622B74497E0085D825 /* codeBasedPropositionJsonContent.json in Resources */ = {isa = PBXBuildFile; fileRef = 09265D5E2B74497E0085D825 /* codeBasedPropositionJsonContent.json */; }; + 09265D632B74497E0085D825 /* codeBasedPropositionJson.json in Resources */ = {isa = PBXBuildFile; fileRef = 09265D5F2B74497E0085D825 /* codeBasedPropositionJson.json */; }; + 092A77F22A757CB40026D325 /* CodeBasedOffersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092A77F12A757CB40026D325 /* CodeBasedOffersView.swift */; }; + 094C4E9E2A74FC4200D99C70 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094C4E9D2A74FC4200D99C70 /* FeedItemView.swift */; }; + 0969D6342A75D55E00A00BF7 /* CustomImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969D6332A75D55E00A00BF7 /* CustomImageView.swift */; }; + 0969D6362A760AF900A00BF7 /* CustomHtmlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969D6352A760AF900A00BF7 /* CustomHtmlView.swift */; }; + 0969D6382A79BB3C00A00BF7 /* Messaging+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969D6372A79BB3C00A00BF7 /* Messaging+State.swift */; }; + 0969D63C2A7A0EF600A00BF7 /* CustomTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969D63B2A7A0EF600A00BF7 /* CustomTextView.swift */; }; + 0969D6402A7A9DC600A00BF7 /* MessagingMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0969D63F2A7A9DC600A00BF7 /* MessagingMigrator.swift */; }; + 0969D6D12A7AFB3800A00BF7 /* Preview Content in Resources */ = {isa = PBXBuildFile; fileRef = 0969D6D02A7AFB3800A00BF7 /* Preview Content */; }; + 096E19922A758D1600D4EBCF /* FeedItemDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096E19912A758D1600D4EBCF /* FeedItemDetailView.swift */; }; + 09B071E62A64D3D900F259C1 /* Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B071E52A64D3D900F259C1 /* Surface.swift */; }; + 09B071E82A64D80E00F259C1 /* Bundle+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B071E72A64D80E00F259C1 /* Bundle+Messaging.swift */; }; + 09B071EC2A651C7800F259C1 /* Proposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B071EB2A651C7800F259C1 /* Proposition.swift */; }; + 09B071EE2A651CB200F259C1 /* PropositionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B071ED2A651CB200F259C1 /* PropositionItem.swift */; }; + 09B071F62A7318CB00F259C1 /* Array+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09B071F52A7318CB00F259C1 /* Array+Messaging.swift */; }; + 09F5D9402B5CF35F00117437 /* PropositionInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F5D93F2B5CF35F00117437 /* PropositionInteraction.swift */; }; 2402745C29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2402745B29FC424000884DFE /* TestableMessagingDelegate.swift */; }; 2402745D29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2402745B29FC424000884DFE /* TestableMessagingDelegate.swift */; }; 2402745E29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2402745B29FC424000884DFE /* TestableMessagingDelegate.swift */; }; + 240316B82A83DDD80016B0D9 /* Cache+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240316B72A83DDD80016B0D9 /* Cache+Messaging.swift */; }; + 240BDFF72B72F10000AE8547 /* mockPropositionItem.json in Resources */ = {isa = PBXBuildFile; fileRef = 240BDFF52B72E83E00AE8547 /* mockPropositionItem.json */; }; 240F71FB26868F7100846587 /* SharedStateResult+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240F71FA26868F7100846587 /* SharedStateResult+Messaging.swift */; }; + 240FC42E2AA920D400AFEEEB /* ParsedPropositions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240FC42D2AA920D400AFEEEB /* ParsedPropositions.swift */; }; + 240FC4302AAFB08E00AFEEEB /* ParsedPropositionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240FC42F2AAFB08E00AFEEEB /* ParsedPropositionsTests.swift */; }; + 240FC4362AAFCBE500AFEEEB /* feedProposition.json in Resources */ = {isa = PBXBuildFile; fileRef = 240FC4352AAFCBE500AFEEEB /* feedProposition.json */; }; + 240FC4382AAFCE3400AFEEEB /* inappPropositionV2.json in Resources */ = {isa = PBXBuildFile; fileRef = 240FC4372AAFCE3400AFEEEB /* inappPropositionV2.json */; }; + 240FC43F2AB0B8A300AFEEEB /* feedPropositionContent.json in Resources */ = {isa = PBXBuildFile; fileRef = 240FC43B2AB0B8A200AFEEEB /* feedPropositionContent.json */; }; + 240FC4422AB0B8A300AFEEEB /* inappPropositionV2Content.json in Resources */ = {isa = PBXBuildFile; fileRef = 240FC43E2AB0B8A300AFEEEB /* inappPropositionV2Content.json */; }; 2414ED832899BA080036D505 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 2414ED822899BA080036D505 /* AppDelegate.m */; }; 2414ED862899BA080036D505 /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 2414ED852899BA080036D505 /* SceneDelegate.m */; }; 2414ED892899BA080036D505 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2414ED882899BA080036D505 /* ViewController.m */; }; @@ -38,14 +77,24 @@ 2415598528D1217500729136 /* nativeMethodCallingSample.html in Resources */ = {isa = PBXBuildFile; fileRef = 2415598428D1217500729136 /* nativeMethodCallingSample.html */; }; 241B2DD42821C80C00E4FF67 /* URL+QueryParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 241B2DD32821C80C00E4FF67 /* URL+QueryParams.swift */; }; 241B2DD62821C99500E4FF67 /* URL+QueryParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 241B2DD52821C99500E4FF67 /* URL+QueryParamsTests.swift */; }; + 242920D42ABCA559000DB2CD /* ruleWithNoConsequence.json in Resources */ = {isa = PBXBuildFile; fileRef = 242920D32ABCA559000DB2CD /* ruleWithNoConsequence.json */; }; + 242920D62ABCD8A6000DB2CD /* ruleWithUnknownConsequenceSchema.json in Resources */ = {isa = PBXBuildFile; fileRef = 242920D52ABCD8A6000DB2CD /* ruleWithUnknownConsequenceSchema.json */; }; + 242920DA2ABCF488000DB2CD /* RuleConsequence+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920D92ABCF488000DB2CD /* RuleConsequence+MessagingTests.swift */; }; + 242920E42AC4D060000DB2CD /* FeedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920E22AC4D060000DB2CD /* FeedTests.swift */; }; + 242920E92AC4EE2D000DB2CD /* Messaging+StateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920E82AC4EE2D000DB2CD /* Messaging+StateTests.swift */; }; + 242920EB2AC5D745000DB2CD /* cachedProposition.json in Resources */ = {isa = PBXBuildFile; fileRef = 242920EA2AC5D744000DB2CD /* cachedProposition.json */; }; + 242920ED2ACF7760000DB2CD /* SchemaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920EC2ACF7760000DB2CD /* SchemaType.swift */; }; + 242920EF2AD05AFA000DB2CD /* InAppSchemaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920EE2AD05AFA000DB2CD /* InAppSchemaData.swift */; }; + 242920F12AD061F3000DB2CD /* FeedItemSchemaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920F02AD061F3000DB2CD /* FeedItemSchemaData.swift */; }; + 242920F32AD06207000DB2CD /* JsonContentSchemaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920F22AD06207000DB2CD /* JsonContentSchemaData.swift */; }; + 242920F52AD06215000DB2CD /* HtmlContentSchemaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920F42AD06215000DB2CD /* HtmlContentSchemaData.swift */; }; + 242920F82AD06791000DB2CD /* RulesetSchemaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242920F72AD06791000DB2CD /* RulesetSchemaData.swift */; }; 2438B92C29C10B2D001D6F3A /* wrongScopeRule.json in Resources */ = {isa = PBXBuildFile; fileRef = 2438B92B29C10B2D001D6F3A /* wrongScopeRule.json */; }; 2438B92F29C12179001D6F3A /* emptyContentStringRule.json in Resources */ = {isa = PBXBuildFile; fileRef = 2438B92D29C12179001D6F3A /* emptyContentStringRule.json */; }; 2438B93029C12179001D6F3A /* malformedContentRule.json in Resources */ = {isa = PBXBuildFile; fileRef = 2438B92E29C12179001D6F3A /* malformedContentRule.json */; }; 243B1AFC28AD7FCE0074327E /* PropositionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243B1AFB28AD7FCE0074327E /* PropositionPayload.swift */; }; 243B1AFE28AEB1E60074327E /* PropositionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243B1AFD28AEB1E60074327E /* PropositionInfo.swift */; }; - 243B1B0028B411630074327E /* PayloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243B1AFF28B411630074327E /* PayloadItem.swift */; }; - 243B1B0228B411890074327E /* ItemData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243B1B0128B411890074327E /* ItemData.swift */; }; - 243EA6C92733258600195945 /* Dictionary+MergingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6C82733258600195945 /* Dictionary+MergingTests.swift */; }; + 243EA6C92733258600195945 /* Dictionary+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6C82733258600195945 /* Dictionary+MessagingTests.swift */; }; 243EA6CB273325A000195945 /* FullscreenMessage+MessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6CA273325A000195945 /* FullscreenMessage+MessageTests.swift */; }; 243EA6CF273325CC00195945 /* Message+FullscreenMessageDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6CE273325CC00195945 /* Message+FullscreenMessageDelegateTests.swift */; }; 243EA6D0273325DC00195945 /* MessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6CC273325B700195945 /* MessageTests.swift */; }; @@ -62,8 +111,11 @@ 244C2BDE26B36A4B008F086A /* Message+FullscreenMessageDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244C2BDD26B36A4B008F086A /* Message+FullscreenMessageDelegate.swift */; }; 244E954B267BAEBE001DC957 /* Messaging+EdgeEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244E954A267BAEBE001DC957 /* Messaging+EdgeEvents.swift */; }; 244E9555267BB018001DC957 /* String+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244E9554267BB018001DC957 /* String+JSON.swift */; }; - 244E955B267BB253001DC957 /* Dictionary+Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244E955A267BB253001DC957 /* Dictionary+Merging.swift */; }; + 244E955B267BB253001DC957 /* Dictionary+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244E955A267BB253001DC957 /* Dictionary+Messaging.swift */; }; 244E9584268262C8001DC957 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244E9583268262C7001DC957 /* Message.swift */; }; + 244FEA4429B6A1060058FA1C /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244FEA4329B6A1060058FA1C /* FeedItem.swift */; }; + 244FEA4629B6A5D30058FA1C /* FeedItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244FEA4529B6A5D30058FA1C /* FeedItemTests.swift */; }; + 244FEA4829B8E2950058FA1C /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244FEA4729B8E2950058FA1C /* Feed.swift */; }; 245059522671283F00CC7CA0 /* MessagingRulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2450594D2671283F00CC7CA0 /* MessagingRulesEngine.swift */; }; 2450596F2673DBFE00CC7CA0 /* Event+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 245059622673DBFD00CC7CA0 /* Event+MessagingTests.swift */; }; 245059702673DBFE00CC7CA0 /* Messaging+PublicApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 245059632673DBFD00CC7CA0 /* Messaging+PublicApiTest.swift */; }; @@ -74,6 +126,7 @@ 245059A22673FAC200CC7CA0 /* Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92315436261E3B36004AE7D3 /* Messaging.swift */; }; 245059A72673FAC700CC7CA0 /* Event+Messaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 923155762620FC53004AE7D3 /* Event+Messaging.swift */; }; 24552E69291F08CF000744AD /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24552E68291F08CF000744AD /* Environment.swift */; }; + 24569A8F2AE2CD6E00FC356F /* ContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24569A8E2AE2CD6E00FC356F /* ContentType.swift */; }; 2469A5DC27445CAF00E56457 /* MockLaunchRulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2469A5DB27445CAF00E56457 /* MockLaunchRulesEngine.swift */; }; 2469A5DF274465C900E56457 /* showOnceRule.json in Resources */ = {isa = PBXBuildFile; fileRef = 2469A5DE274465C900E56457 /* showOnceRule.json */; }; 2469A5E12744696400E56457 /* eventSequenceRule.json in Resources */ = {isa = PBXBuildFile; fileRef = 2469A5E02744696400E56457 /* eventSequenceRule.json */; }; @@ -93,7 +146,6 @@ 246EFA1127973E7400C76A6B /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; platformFilter = ios; }; 246EFA19279743EB00C76A6B /* ConfigurationLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2469A5FA2759401900E56457 /* ConfigurationLoader.swift */; }; 246EFA1A2797441600C76A6B /* Dictionary+Flatten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928639FB263757A7000AFA53 /* Dictionary+Flatten.swift */; }; - 246EFA1B2797441600C76A6B /* EventHub+Testable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC58C7263688F0005BAE02 /* EventHub+Testable.swift */; }; 246EFA1C2797441600C76A6B /* JSONFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2469A5EA274D49B100E56457 /* JSONFileLoader.swift */; }; 246EFA1D2797441600C76A6B /* MockCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2469A5E8274C107100E56457 /* MockCache.swift */; }; 246EFA1E2797441600C76A6B /* MockExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543F261E3B36004AE7D3 /* MockExtension.swift */; }; @@ -102,17 +154,12 @@ 246EFA212797441600C76A6B /* MockMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6DB2739D48900195945 /* MockMessage.swift */; }; 246EFA222797441600C76A6B /* MockMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6D92739D47400195945 /* MockMessaging.swift */; }; 246EFA232797441600C76A6B /* MockMessagingRulesEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6E1273B436400195945 /* MockMessagingRulesEngine.swift */; }; - 246EFA242797441600C76A6B /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543E261E3B36004AE7D3 /* MockNetworkService.swift */; }; 246EFA252797441600C76A6B /* MockNotificationResponseCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC594426372E34005BAE02 /* MockNotificationResponseCoder.swift */; }; - 246EFA262797441600C76A6B /* TestableExtensionRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543D261E3B36004AE7D3 /* TestableExtensionRuntime.swift */; }; 246EFA272797441600C76A6B /* TestableMessagingMobileParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243EA6DF2739D9D700195945 /* TestableMessagingMobileParameters.swift */; }; - 246EFA282797441600C76A6B /* TestableNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928639D026374463000AFA53 /* TestableNetworkService.swift */; }; 246EFA292797620800C76A6B /* functionalTestConfigStage.json in Resources */ = {isa = PBXBuildFile; fileRef = 2469A5EC2755A60E00E56457 /* functionalTestConfigStage.json */; }; 246FD07226B9F86F00FD130B /* FullscreenMessage+Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 246FD07126B9F86F00FD130B /* FullscreenMessage+Message.swift */; }; - 248BD9C828BD568400C49B94 /* PayloadItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248BD9C728BD568400C49B94 /* PayloadItemTests.swift */; }; 248BD9CA28BD56A200C49B94 /* PropositionInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248BD9C928BD56A200C49B94 /* PropositionInfoTests.swift */; }; 248BD9CC28BD56B300C49B94 /* PropositionPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248BD9CB28BD56B300C49B94 /* PropositionPayloadTests.swift */; }; - 248BD9CE28BD56CF00C49B94 /* ItemDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248BD9CD28BD56CF00C49B94 /* ItemDataTests.swift */; }; 24B071A829072E9800F4B18A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B071A729072E9800F4B18A /* AppDelegate.swift */; }; 24B071AA29072E9800F4B18A /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B071A929072E9800F4B18A /* SceneDelegate.swift */; }; 24B071AC29072E9800F4B18A /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B071AB29072E9800F4B18A /* ViewController.swift */; }; @@ -122,14 +169,29 @@ 24B071BA29072EBF00F4B18A /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; 24B3413728F724CB00D07FB1 /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; 24B3413828F724CB00D07FB1 /* AEPMessaging.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 24CA4BC12B6178EE00D16369 /* InAppSchemaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BC02B6178EE00D16369 /* InAppSchemaDataTests.swift */; }; + 24CA4BC32B6179F800D16369 /* ContentTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BC22B6179F800D16369 /* ContentTypeTests.swift */; }; + 24CA4BC52B617A1000D16369 /* FeedItemSchemaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BC42B617A1000D16369 /* FeedItemSchemaDataTests.swift */; }; + 24CA4BC72B617A2500D16369 /* HtmlContentSchemaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BC62B617A2500D16369 /* HtmlContentSchemaDataTests.swift */; }; + 24CA4BC92B617A3F00D16369 /* JsonContentSchemaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BC82B617A3F00D16369 /* JsonContentSchemaDataTests.swift */; }; + 24CA4BCB2B617A7100D16369 /* RulesetSchemaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BCA2B617A7100D16369 /* RulesetSchemaDataTests.swift */; }; + 24CA4BCD2B617A8100D16369 /* SchemaTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BCC2B617A8100D16369 /* SchemaTypeTests.swift */; }; + 24CA4BCF2B617AA900D16369 /* Array+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BCE2B617AA900D16369 /* Array+MessagingTests.swift */; }; + 24CA4BD12B617AC800D16369 /* Bundle+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BD02B617AC800D16369 /* Bundle+MessagingTests.swift */; }; + 24CA4BD32B617AED00D16369 /* Cache+MessagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BD22B617AED00D16369 /* Cache+MessagingTests.swift */; }; + 24CA4BD52B6195A500D16369 /* FeedRulesEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BD42B6195A500D16369 /* FeedRulesEngineTests.swift */; }; + 24CA4BD92B6196DF00D16369 /* MessagingMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BD82B6196DF00D16369 /* MessagingMigratorTests.swift */; }; + 24CA4BDB2B61971000D16369 /* PropositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BDA2B61971000D16369 /* PropositionTests.swift */; }; + 24CA4BDD2B61972500D16369 /* PropositionItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BDC2B61972500D16369 /* PropositionItemTests.swift */; }; + 24CA4BDF2B61974300D16369 /* PropositionInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BDE2B61974300D16369 /* PropositionInteractionTests.swift */; }; + 24CA4BE12B61977800D16369 /* SurfaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24CA4BE02B61977800D16369 /* SurfaceTests.swift */; }; 24EE301E28FF61F0005E417C /* InAppMessagingEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24EE301D28FF61F0005E417C /* InAppMessagingEventTests.swift */; }; 2B2B7A6E0F7CD6312D28421A /* Pods_FunctionalTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD29CE4BC879CE75AFEFF0D5 /* Pods_FunctionalTests.framework */; }; 44DFBD558578CFDCD4A9A476 /* Pods_E2EFunctionalTestApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A44BD44CB3A7DA5EBB972652 /* Pods_E2EFunctionalTestApp.framework */; }; 56B08B522365B6DC2B556B40 /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D685E0AE5C614B4788DE869A /* Pods_UnitTests.framework */; }; 75B4333CC0B4F5BFAC707055 /* Pods_MessagingDemoAppObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28E46BC7A8F3939CF2DDDC8F /* Pods_MessagingDemoAppObjC.framework */; }; 8C4723D49BD3F1EB4060FEAE /* Pods_FunctionalTestApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB9F1A791A757B3C68D23B9D /* Pods_FunctionalTestApp.framework */; }; - 9231545E261E3B6F004AE7D3 /* TestableExtensionRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543D261E3B36004AE7D3 /* TestableExtensionRuntime.swift */; }; - 92315465261E3B72004AE7D3 /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543E261E3B36004AE7D3 /* MockNetworkService.swift */; }; + 8DEEA93D16C40CA285E20AD5 /* Pods_MessagingDemoAppSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91A11E37A49E14EB847FB2DF /* Pods_MessagingDemoAppSwiftUI.framework */; }; 9231546C261E3B75004AE7D3 /* MockExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543F261E3B36004AE7D3 /* MockExtension.swift */; }; 92315508261E444A004AE7D3 /* AEPMessaging.h in Headers */ = {isa = PBXBuildFile; fileRef = 92315435261E3B36004AE7D3 /* AEPMessaging.h */; settings = {ATTRIBUTES = (Public, ); }; }; 9249A4E2252292AB009193AB /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; @@ -141,17 +203,11 @@ 928594162522880900B2BE47 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 925DF4482522785700A5DE31 /* LaunchScreen.storyboard */; }; 9285941C2522899700B2BE47 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 925DF44A2522785700A5DE31 /* Main.storyboard */; }; 9285941E25228A1500B2BE47 /* ADBMobileConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = 925DF44E2522785700A5DE31 /* ADBMobileConfig.json */; }; - 928639D126374463000AFA53 /* TestableNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928639D026374463000AFA53 /* TestableNetworkService.swift */; }; - 928639D226374463000AFA53 /* TestableNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928639D026374463000AFA53 /* TestableNetworkService.swift */; }; 928639FC263757A7000AFA53 /* Dictionary+Flatten.swift in Sources */ = {isa = PBXBuildFile; fileRef = 928639FB263757A7000AFA53 /* Dictionary+Flatten.swift */; }; 92863A022637706F000AFA53 /* MessagingFunctionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92863A012637706F000AFA53 /* MessagingFunctionalTests.swift */; }; 9296BA862537BFC0002C88F7 /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; 92FC587B2636840C005BAE02 /* MessagingPublicAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC587A2636840C005BAE02 /* MessagingPublicAPITests.swift */; }; 92FC587D2636840C005BAE02 /* AEPMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */; }; - 92FC58C8263688F0005BAE02 /* EventHub+Testable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC58C7263688F0005BAE02 /* EventHub+Testable.swift */; }; - 92FC58C9263688F0005BAE02 /* EventHub+Testable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC58C7263688F0005BAE02 /* EventHub+Testable.swift */; }; - 92FC58CE263688FD005BAE02 /* TestableExtensionRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543D261E3B36004AE7D3 /* TestableExtensionRuntime.swift */; }; - 92FC58D326368900005BAE02 /* MockNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543E261E3B36004AE7D3 /* MockNetworkService.swift */; }; 92FC58D826368901005BAE02 /* MockExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9231543F261E3B36004AE7D3 /* MockExtension.swift */; }; 92FC594526372E34005BAE02 /* MockNotificationResponseCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC594426372E34005BAE02 /* MockNotificationResponseCoder.swift */; }; 92FC594626372E34005BAE02 /* MockNotificationResponseCoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FC594426372E34005BAE02 /* MockNotificationResponseCoder.swift */; }; @@ -172,6 +228,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 091881F82A16D12E00615481 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 922FFCE7251B2BBA00BCE010 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 925DF4A425227C4700A5DE31; + remoteInfo = AEPMessaging; + }; 2414ED9A2899BAC50036D505 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 922FFCE7251B2BBA00BCE010 /* Project object */; @@ -249,9 +312,34 @@ remoteGlobalIDString = B6165DA229A67AD90031B84D; remoteInfo = NotificationService; }; + B6D6AE912BA8C48100F0E975 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 922FFCE7251B2BBA00BCE010 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2469A6012759999E00E56457; + remoteInfo = FunctionalTestApp; + }; + B6D6AE932BA8E50600F0E975 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 922FFCE7251B2BBA00BCE010 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2469A6012759999E00E56457; + remoteInfo = FunctionalTestApp; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 091881FD2A16D15100615481 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 091881FC2A16D15100615481 /* AEPMessaging.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 2414ED9C2899BAC50036D505 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -299,9 +387,47 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 090290C429DCED0B00388226 /* FeedRulesEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRulesEngine.swift; sourceTree = ""; }; + 090290C829DD11EA00388226 /* RuleConsequence+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuleConsequence+Messaging.swift"; sourceTree = ""; }; + 090290CA29DE3F8200388226 /* MockFeedRulesEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MockFeedRulesEngine.swift; path = AEPMessaging/Tests/TestHelpers/MockFeedRulesEngine.swift; sourceTree = SOURCE_ROOT; }; + 091881E42A16BAE200615481 /* MessagingDemoAppSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagingDemoAppSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 091881E62A16BAE300615481 /* MessagingDemoAppSwiftUIApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingDemoAppSwiftUIApp.swift; sourceTree = ""; }; + 091881E82A16BAE300615481 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 091881EA2A16BAE400615481 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 091881F22A16C2A200615481 /* InAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppView.swift; sourceTree = ""; }; + 091881F52A16C2D600615481 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; + 091881FE2A16D7A200615481 /* PushView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushView.swift; sourceTree = ""; }; + 09265D5C2B74497E0085D825 /* codeBasedPropositionHtml.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = codeBasedPropositionHtml.json; sourceTree = ""; }; + 09265D5D2B74497E0085D825 /* codeBasedPropositionHtmlContent.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = codeBasedPropositionHtmlContent.json; sourceTree = ""; }; + 09265D5E2B74497E0085D825 /* codeBasedPropositionJsonContent.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = codeBasedPropositionJsonContent.json; sourceTree = ""; }; + 09265D5F2B74497E0085D825 /* codeBasedPropositionJson.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = codeBasedPropositionJson.json; sourceTree = ""; }; + 092A77F12A757CB40026D325 /* CodeBasedOffersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBasedOffersView.swift; sourceTree = ""; }; + 09305F0B2AC2EAFF00406607 /* MessagingDemoAppSwiftUI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "MessagingDemoAppSwiftUI-Info.plist"; sourceTree = SOURCE_ROOT; }; 093DC9CB668BBA547B0C9306 /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = ""; }; + 094C4E9D2A74FC4200D99C70 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = ""; }; + 0969D6332A75D55E00A00BF7 /* CustomImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomImageView.swift; sourceTree = ""; }; + 0969D6352A760AF900A00BF7 /* CustomHtmlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomHtmlView.swift; sourceTree = ""; }; + 0969D6372A79BB3C00A00BF7 /* Messaging+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Messaging+State.swift"; sourceTree = ""; }; + 0969D63B2A7A0EF600A00BF7 /* CustomTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTextView.swift; sourceTree = ""; }; + 0969D63F2A7A9DC600A00BF7 /* MessagingMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingMigrator.swift; sourceTree = ""; }; + 0969D6D02A7AFB3800A00BF7 /* Preview Content */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "Preview Content"; sourceTree = ""; }; + 096E19912A758D1600D4EBCF /* FeedItemDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemDetailView.swift; sourceTree = ""; }; + 09B071E52A64D3D900F259C1 /* Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Surface.swift; sourceTree = ""; }; + 09B071E72A64D80E00F259C1 /* Bundle+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Messaging.swift"; sourceTree = ""; }; + 09B071EB2A651C7800F259C1 /* Proposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Proposition.swift; sourceTree = ""; }; + 09B071ED2A651CB200F259C1 /* PropositionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionItem.swift; sourceTree = ""; }; + 09B071F52A7318CB00F259C1 /* Array+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Messaging.swift"; sourceTree = ""; }; + 09F5D93F2B5CF35F00117437 /* PropositionInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionInteraction.swift; sourceTree = ""; }; 2402745B29FC424000884DFE /* TestableMessagingDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableMessagingDelegate.swift; sourceTree = ""; }; + 240316B72A83DDD80016B0D9 /* Cache+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cache+Messaging.swift"; sourceTree = ""; }; + 240BDFF52B72E83E00AE8547 /* mockPropositionItem.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = mockPropositionItem.json; sourceTree = ""; }; 240F71FA26868F7100846587 /* SharedStateResult+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharedStateResult+Messaging.swift"; sourceTree = ""; }; + 240FC42D2AA920D400AFEEEB /* ParsedPropositions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsedPropositions.swift; sourceTree = ""; }; + 240FC42F2AAFB08E00AFEEEB /* ParsedPropositionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsedPropositionsTests.swift; sourceTree = ""; }; + 240FC4352AAFCBE500AFEEEB /* feedProposition.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = feedProposition.json; sourceTree = ""; }; + 240FC4372AAFCE3400AFEEEB /* inappPropositionV2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = inappPropositionV2.json; sourceTree = ""; }; + 240FC43B2AB0B8A200AFEEEB /* feedPropositionContent.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = feedPropositionContent.json; sourceTree = ""; }; + 240FC43E2AB0B8A300AFEEEB /* inappPropositionV2Content.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = inappPropositionV2Content.json; sourceTree = ""; }; 2414ED7F2899BA080036D505 /* MessagingDemoAppObjC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagingDemoAppObjC.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2414ED812899BA080036D505 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 2414ED822899BA080036D505 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -317,14 +443,24 @@ 2415598428D1217500729136 /* nativeMethodCallingSample.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = nativeMethodCallingSample.html; sourceTree = ""; }; 241B2DD32821C80C00E4FF67 /* URL+QueryParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+QueryParams.swift"; sourceTree = ""; }; 241B2DD52821C99500E4FF67 /* URL+QueryParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+QueryParamsTests.swift"; sourceTree = ""; }; + 242920D32ABCA559000DB2CD /* ruleWithNoConsequence.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ruleWithNoConsequence.json; sourceTree = ""; }; + 242920D52ABCD8A6000DB2CD /* ruleWithUnknownConsequenceSchema.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ruleWithUnknownConsequenceSchema.json; sourceTree = ""; }; + 242920D92ABCF488000DB2CD /* RuleConsequence+MessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RuleConsequence+MessagingTests.swift"; sourceTree = ""; }; + 242920E22AC4D060000DB2CD /* FeedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedTests.swift; sourceTree = ""; }; + 242920E82AC4EE2D000DB2CD /* Messaging+StateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Messaging+StateTests.swift"; sourceTree = ""; }; + 242920EA2AC5D744000DB2CD /* cachedProposition.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = cachedProposition.json; sourceTree = ""; }; + 242920EC2ACF7760000DB2CD /* SchemaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaType.swift; sourceTree = ""; }; + 242920EE2AD05AFA000DB2CD /* InAppSchemaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppSchemaData.swift; sourceTree = ""; }; + 242920F02AD061F3000DB2CD /* FeedItemSchemaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemSchemaData.swift; sourceTree = ""; }; + 242920F22AD06207000DB2CD /* JsonContentSchemaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonContentSchemaData.swift; sourceTree = ""; }; + 242920F42AD06215000DB2CD /* HtmlContentSchemaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlContentSchemaData.swift; sourceTree = ""; }; + 242920F72AD06791000DB2CD /* RulesetSchemaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesetSchemaData.swift; sourceTree = ""; }; 2438B92B29C10B2D001D6F3A /* wrongScopeRule.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = wrongScopeRule.json; sourceTree = ""; }; 2438B92D29C12179001D6F3A /* emptyContentStringRule.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emptyContentStringRule.json; sourceTree = ""; }; 2438B92E29C12179001D6F3A /* malformedContentRule.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = malformedContentRule.json; sourceTree = ""; }; 243B1AFB28AD7FCE0074327E /* PropositionPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionPayload.swift; sourceTree = ""; }; 243B1AFD28AEB1E60074327E /* PropositionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionInfo.swift; sourceTree = ""; }; - 243B1AFF28B411630074327E /* PayloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayloadItem.swift; sourceTree = ""; }; - 243B1B0128B411890074327E /* ItemData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemData.swift; sourceTree = ""; }; - 243EA6C82733258600195945 /* Dictionary+MergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+MergingTests.swift"; sourceTree = ""; }; + 243EA6C82733258600195945 /* Dictionary+MessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+MessagingTests.swift"; sourceTree = ""; }; 243EA6CA273325A000195945 /* FullscreenMessage+MessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FullscreenMessage+MessageTests.swift"; sourceTree = ""; }; 243EA6CC273325B700195945 /* MessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTests.swift; sourceTree = ""; }; 243EA6CE273325CC00195945 /* Message+FullscreenMessageDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+FullscreenMessageDelegateTests.swift"; sourceTree = ""; }; @@ -341,8 +477,11 @@ 244C2BDD26B36A4B008F086A /* Message+FullscreenMessageDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+FullscreenMessageDelegate.swift"; sourceTree = ""; }; 244E954A267BAEBE001DC957 /* Messaging+EdgeEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Messaging+EdgeEvents.swift"; sourceTree = ""; }; 244E9554267BB018001DC957 /* String+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+JSON.swift"; sourceTree = ""; }; - 244E955A267BB253001DC957 /* Dictionary+Merging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Merging.swift"; sourceTree = ""; }; + 244E955A267BB253001DC957 /* Dictionary+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Messaging.swift"; sourceTree = ""; }; 244E9583268262C7001DC957 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; + 244FEA4329B6A1060058FA1C /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; + 244FEA4529B6A5D30058FA1C /* FeedItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemTests.swift; sourceTree = ""; }; + 244FEA4729B8E2950058FA1C /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 2450594D2671283F00CC7CA0 /* MessagingRulesEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessagingRulesEngine.swift; sourceTree = ""; }; 245059612673DBFD00CC7CA0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 245059622673DBFD00CC7CA0 /* Event+MessagingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Event+MessagingTests.swift"; sourceTree = ""; }; @@ -350,6 +489,7 @@ 245059642673DBFD00CC7CA0 /* MessagingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessagingTests.swift; sourceTree = ""; }; 2450596B2673DBFE00CC7CA0 /* MessagingRulesEngineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessagingRulesEngineTests.swift; sourceTree = ""; }; 24552E68291F08CF000744AD /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; + 24569A8E2AE2CD6E00FC356F /* ContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentType.swift; sourceTree = ""; }; 2469A5DB27445CAF00E56457 /* MockLaunchRulesEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLaunchRulesEngine.swift; sourceTree = ""; }; 2469A5DE274465C900E56457 /* showOnceRule.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = showOnceRule.json; sourceTree = ""; }; 2469A5E02744696400E56457 /* eventSequenceRule.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = eventSequenceRule.json; sourceTree = ""; }; @@ -370,10 +510,8 @@ 246EFA0D27973E7400C76A6B /* E2EFunctionalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = E2EFunctionalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 246EFA0F27973E7400C76A6B /* E2EFunctionalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EFunctionalTests.swift; sourceTree = ""; }; 246FD07126B9F86F00FD130B /* FullscreenMessage+Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FullscreenMessage+Message.swift"; sourceTree = ""; }; - 248BD9C728BD568400C49B94 /* PayloadItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayloadItemTests.swift; sourceTree = ""; }; 248BD9C928BD56A200C49B94 /* PropositionInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionInfoTests.swift; sourceTree = ""; }; 248BD9CB28BD56B300C49B94 /* PropositionPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionPayloadTests.swift; sourceTree = ""; }; - 248BD9CD28BD56CF00C49B94 /* ItemDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDataTests.swift; sourceTree = ""; }; 24B071A529072E9800F4B18A /* E2EFunctionalTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = E2EFunctionalTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 24B071A729072E9800F4B18A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 24B071A929072E9800F4B18A /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -382,6 +520,22 @@ 24B071B029072E9900F4B18A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 24B071B329072E9900F4B18A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 24B071B529072E9900F4B18A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 24CA4BC02B6178EE00D16369 /* InAppSchemaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppSchemaDataTests.swift; sourceTree = ""; }; + 24CA4BC22B6179F800D16369 /* ContentTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTypeTests.swift; sourceTree = ""; }; + 24CA4BC42B617A1000D16369 /* FeedItemSchemaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemSchemaDataTests.swift; sourceTree = ""; }; + 24CA4BC62B617A2500D16369 /* HtmlContentSchemaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlContentSchemaDataTests.swift; sourceTree = ""; }; + 24CA4BC82B617A3F00D16369 /* JsonContentSchemaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonContentSchemaDataTests.swift; sourceTree = ""; }; + 24CA4BCA2B617A7100D16369 /* RulesetSchemaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesetSchemaDataTests.swift; sourceTree = ""; }; + 24CA4BCC2B617A8100D16369 /* SchemaTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaTypeTests.swift; sourceTree = ""; }; + 24CA4BCE2B617AA900D16369 /* Array+MessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+MessagingTests.swift"; sourceTree = ""; }; + 24CA4BD02B617AC800D16369 /* Bundle+MessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+MessagingTests.swift"; sourceTree = ""; }; + 24CA4BD22B617AED00D16369 /* Cache+MessagingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cache+MessagingTests.swift"; sourceTree = ""; }; + 24CA4BD42B6195A500D16369 /* FeedRulesEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRulesEngineTests.swift; sourceTree = ""; }; + 24CA4BD82B6196DF00D16369 /* MessagingMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingMigratorTests.swift; sourceTree = ""; }; + 24CA4BDA2B61971000D16369 /* PropositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionTests.swift; sourceTree = ""; }; + 24CA4BDC2B61972500D16369 /* PropositionItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionItemTests.swift; sourceTree = ""; }; + 24CA4BDE2B61974300D16369 /* PropositionInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropositionInteractionTests.swift; sourceTree = ""; }; + 24CA4BE02B61977800D16369 /* SurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTests.swift; sourceTree = ""; }; 24EE301D28FF61F0005E417C /* InAppMessagingEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagingEventTests.swift; sourceTree = ""; }; 28E46BC7A8F3939CF2DDDC8F /* Pods_MessagingDemoAppObjC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MessagingDemoAppObjC.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 38F4A4735D3FB26229C244BC /* Pods-MessagingDemoApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MessagingDemoApp.release.xcconfig"; path = "Target Support Files/Pods-MessagingDemoApp/Pods-MessagingDemoApp.release.xcconfig"; sourceTree = ""; }; @@ -389,17 +543,18 @@ 3B967A1DE66A327FF7FB3F7A /* Pods-MessagingDemoAppObjC.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MessagingDemoAppObjC.release.xcconfig"; path = "Target Support Files/Pods-MessagingDemoAppObjC/Pods-MessagingDemoAppObjC.release.xcconfig"; sourceTree = ""; }; 4A6935385186A1A96989B8F0 /* Pods-FunctionalTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTests.release.xcconfig"; path = "Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests.release.xcconfig"; sourceTree = ""; }; 4AD2B66914A5CF6D57AB25A7 /* Pods-FunctionalTestApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTestApp.debug.xcconfig"; path = "Target Support Files/Pods-FunctionalTestApp/Pods-FunctionalTestApp.debug.xcconfig"; sourceTree = ""; }; + 4C3B9F882AE3385B00A7D395 /* AEPTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AEPTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4DBE357820DAAEE02A221D75 /* Pods-E2EFunctionalTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2EFunctionalTests.debug.xcconfig"; path = "Target Support Files/Pods-E2EFunctionalTests/Pods-E2EFunctionalTests.debug.xcconfig"; sourceTree = ""; }; 53FA287A1BFC0714D83A6419 /* 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 = ""; }; 69F23BE01498B5A36EEE4CDC /* Pods_MessagingDemoApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MessagingDemoApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7BB455BF61D9D261AC490547 /* Pods-MessagingDemoAppSwiftUI.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MessagingDemoAppSwiftUI.debug.xcconfig"; path = "Target Support Files/Pods-MessagingDemoAppSwiftUI/Pods-MessagingDemoAppSwiftUI.debug.xcconfig"; sourceTree = ""; }; + 91A11E37A49E14EB847FB2DF /* Pods_MessagingDemoAppSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MessagingDemoAppSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 92085982251BEB7100B9C65A /* MessagingDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MessagingDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 92315434261E3B36004AE7D3 /* Messaging+PublicAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Messaging+PublicAPI.swift"; sourceTree = ""; }; 92315435261E3B36004AE7D3 /* AEPMessaging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AEPMessaging.h; sourceTree = ""; }; 92315436261E3B36004AE7D3 /* Messaging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Messaging.swift; sourceTree = ""; }; 92315437261E3B36004AE7D3 /* MessagingConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessagingConstants.swift; sourceTree = ""; }; 92315438261E3B36004AE7D3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9231543D261E3B36004AE7D3 /* TestableExtensionRuntime.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestableExtensionRuntime.swift; sourceTree = ""; }; - 9231543E261E3B36004AE7D3 /* MockNetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockNetworkService.swift; sourceTree = ""; }; 9231543F261E3B36004AE7D3 /* MockExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockExtension.swift; sourceTree = ""; }; 923155762620FC53004AE7D3 /* Event+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Messaging.swift"; sourceTree = ""; }; 9249A4E8252294AD009193AB /* AEPCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AEPCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -416,7 +571,6 @@ 925DF44F2522785700A5DE31 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 925DF4502522785700A5DE31 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 925DF4A525227C4700A5DE31 /* AEPMessaging.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AEPMessaging.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 928639D026374463000AFA53 /* TestableNetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableNetworkService.swift; sourceTree = ""; }; 928639FB263757A7000AFA53 /* Dictionary+Flatten.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Flatten.swift"; sourceTree = ""; }; 92863A012637706F000AFA53 /* MessagingFunctionalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingFunctionalTests.swift; sourceTree = ""; }; 9296BA812537BFC0002C88F7 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -424,7 +578,6 @@ 92FC58782636840C005BAE02 /* FunctionalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FunctionalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 92FC587A2636840C005BAE02 /* MessagingPublicAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingPublicAPITests.swift; sourceTree = ""; }; 92FC587C2636840C005BAE02 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 92FC58C7263688F0005BAE02 /* EventHub+Testable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventHub+Testable.swift"; sourceTree = ""; }; 92FC594426372E34005BAE02 /* MockNotificationResponseCoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNotificationResponseCoder.swift; sourceTree = ""; }; 96B1543895E5AB667A105654 /* Pods-AEPMessaging.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AEPMessaging.release.xcconfig"; path = "Target Support Files/Pods-AEPMessaging/Pods-AEPMessaging.release.xcconfig"; sourceTree = ""; }; 9C3C5F65B5C739964FD7D12D /* Pods-FunctionalTestApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTestApp.release.xcconfig"; path = "Target Support Files/Pods-FunctionalTestApp/Pods-FunctionalTestApp.release.xcconfig"; sourceTree = ""; }; @@ -451,9 +604,19 @@ EE70278DC8550367F00F9609 /* 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 = ""; }; FB180620A608D2821FE98F37 /* Pods_E2EFunctionalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_E2EFunctionalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FBDFB0BBF7979F0FEEFDE155 /* Pods-E2EFunctionalTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2EFunctionalTests.release.xcconfig"; path = "Target Support Files/Pods-E2EFunctionalTests/Pods-E2EFunctionalTests.release.xcconfig"; sourceTree = ""; }; + FF12209B1A62AAB46BE0E834 /* Pods-MessagingDemoAppSwiftUI.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MessagingDemoAppSwiftUI.release.xcconfig"; path = "Target Support Files/Pods-MessagingDemoAppSwiftUI/Pods-MessagingDemoAppSwiftUI.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 091881E12A16BAE200615481 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 091881FB2A16D15100615481 /* AEPMessaging.framework in Frameworks */, + 8DEEA93D16C40CA285E20AD5 /* Pods_MessagingDemoAppSwiftUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2414ED7C2899BA080036D505 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -535,6 +698,28 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 091881E52A16BAE300615481 /* MessagingDemoAppSwiftUI */ = { + isa = PBXGroup; + children = ( + 09305F0B2AC2EAFF00406607 /* MessagingDemoAppSwiftUI-Info.plist */, + 091881E62A16BAE300615481 /* MessagingDemoAppSwiftUIApp.swift */, + 092A77F12A757CB40026D325 /* CodeBasedOffersView.swift */, + 0969D6352A760AF900A00BF7 /* CustomHtmlView.swift */, + 0969D6332A75D55E00A00BF7 /* CustomImageView.swift */, + 0969D63B2A7A0EF600A00BF7 /* CustomTextView.swift */, + 091881F52A16C2D600615481 /* FeedsView.swift */, + 094C4E9D2A74FC4200D99C70 /* FeedItemView.swift */, + 096E19912A758D1600D4EBCF /* FeedItemDetailView.swift */, + 091881E82A16BAE300615481 /* HomeView.swift */, + 091881F22A16C2A200615481 /* InAppView.swift */, + 091881FE2A16D7A200615481 /* PushView.swift */, + 091881EA2A16BAE400615481 /* Assets.xcassets */, + 0969D6D02A7AFB3800A00BF7 /* Preview Content */, + ); + name = MessagingDemoAppSwiftUI; + path = TestApps/MessagingDemoAppSwiftUI; + sourceTree = ""; + }; 2414ED802899BA080036D505 /* MessagingDemoAppObjC */ = { isa = PBXGroup; children = ( @@ -554,13 +739,39 @@ path = TestApps/MessagingDemoAppObjC; sourceTree = ""; }; + 242920F62AD0628B000DB2CD /* schemas */ = { + isa = PBXGroup; + children = ( + 24569A8E2AE2CD6E00FC356F /* ContentType.swift */, + 242920F02AD061F3000DB2CD /* FeedItemSchemaData.swift */, + 242920F42AD06215000DB2CD /* HtmlContentSchemaData.swift */, + 242920EE2AD05AFA000DB2CD /* InAppSchemaData.swift */, + 242920F22AD06207000DB2CD /* JsonContentSchemaData.swift */, + 242920F72AD06791000DB2CD /* RulesetSchemaData.swift */, + 242920EC2ACF7760000DB2CD /* SchemaType.swift */, + ); + path = schemas; + sourceTree = ""; + }; 2469A5DD2744657000E56457 /* Resources */ = { isa = PBXGroup; children = ( + 09265D5C2B74497E0085D825 /* codeBasedPropositionHtml.json */, + 09265D5D2B74497E0085D825 /* codeBasedPropositionHtmlContent.json */, + 09265D5F2B74497E0085D825 /* codeBasedPropositionJson.json */, + 09265D5E2B74497E0085D825 /* codeBasedPropositionJsonContent.json */, + 242920EA2AC5D744000DB2CD /* cachedProposition.json */, 2438B92D29C12179001D6F3A /* emptyContentStringRule.json */, 2469A5E02744696400E56457 /* eventSequenceRule.json */, + 240FC4352AAFCBE500AFEEEB /* feedProposition.json */, + 240FC43B2AB0B8A200AFEEEB /* feedPropositionContent.json */, 2469A5EC2755A60E00E56457 /* functionalTestConfigStage.json */, + 240FC4372AAFCE3400AFEEEB /* inappPropositionV2.json */, + 240FC43E2AB0B8A300AFEEEB /* inappPropositionV2Content.json */, 2438B92E29C12179001D6F3A /* malformedContentRule.json */, + 240BDFF52B72E83E00AE8547 /* mockPropositionItem.json */, + 242920D32ABCA559000DB2CD /* ruleWithNoConsequence.json */, + 242920D52ABCD8A6000DB2CD /* ruleWithUnknownConsequenceSchema.json */, 2469A5DE274465C900E56457 /* showOnceRule.json */, 2438B92B29C10B2D001D6F3A /* wrongScopeRule.json */, ); @@ -605,6 +816,20 @@ path = E2EFunctionalTestApp; sourceTree = ""; }; + 24CA4BBF2B6178B700D16369 /* schemas */ = { + isa = PBXGroup; + children = ( + 24CA4BC22B6179F800D16369 /* ContentTypeTests.swift */, + 24CA4BC42B617A1000D16369 /* FeedItemSchemaDataTests.swift */, + 24CA4BC62B617A2500D16369 /* HtmlContentSchemaDataTests.swift */, + 24CA4BC02B6178EE00D16369 /* InAppSchemaDataTests.swift */, + 24CA4BC82B617A3F00D16369 /* JsonContentSchemaDataTests.swift */, + 24CA4BCA2B617A7100D16369 /* RulesetSchemaDataTests.swift */, + 24CA4BCC2B617A8100D16369 /* SchemaTypeTests.swift */, + ); + path = schemas; + sourceTree = ""; + }; 2A4D31945863BC9FEBE21FE1 /* Pods */ = { isa = PBXGroup; children = ( @@ -624,6 +849,8 @@ 9C3C5F65B5C739964FD7D12D /* Pods-FunctionalTestApp.release.xcconfig */, CFBC956862FD1C6747587F09 /* Pods-E2EFunctionalTestApp.debug.xcconfig */, CFC660CD22A375E0F809B475 /* Pods-E2EFunctionalTestApp.release.xcconfig */, + 7BB455BF61D9D261AC490547 /* Pods-MessagingDemoAppSwiftUI.debug.xcconfig */, + FF12209B1A62AAB46BE0E834 /* Pods-MessagingDemoAppSwiftUI.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -635,6 +862,7 @@ 925DF4452522785700A5DE31 /* MessagingDemoApp */, 2414ED802899BA080036D505 /* MessagingDemoAppObjC */, B6165DA429A67ADA0031B84D /* NotificationService */, + 091881E52A16BAE300615481 /* MessagingDemoAppSwiftUI */, 922FFCF0251B2BBA00BCE010 /* Products */, 92BD7B0B251C0B7700C758CB /* Frameworks */, 2A4D31945863BC9FEBE21FE1 /* Pods */, @@ -653,6 +881,7 @@ 2414ED7F2899BA080036D505 /* MessagingDemoAppObjC.app */, 24B071A529072E9800F4B18A /* E2EFunctionalTestApp.app */, B6165DA329A67AD90031B84D /* NotificationService.appex */, + 091881E42A16BAE200615481 /* MessagingDemoAppSwiftUI.app */, ); name = Products; sourceTree = ""; @@ -660,26 +889,39 @@ 92315433261E3B36004AE7D3 /* Sources */ = { isa = PBXGroup; children = ( + 242920F62AD0628B000DB2CD /* schemas */, 92315435261E3B36004AE7D3 /* AEPMessaging.h */, - 244E955A267BB253001DC957 /* Dictionary+Merging.swift */, + 09B071F52A7318CB00F259C1 /* Array+Messaging.swift */, + 09B071E72A64D80E00F259C1 /* Bundle+Messaging.swift */, + 240316B72A83DDD80016B0D9 /* Cache+Messaging.swift */, + 244E955A267BB253001DC957 /* Dictionary+Messaging.swift */, 923155762620FC53004AE7D3 /* Event+Messaging.swift */, + 244FEA4729B8E2950058FA1C /* Feed.swift */, + 244FEA4329B6A1060058FA1C /* FeedItem.swift */, + 090290C429DCED0B00388226 /* FeedRulesEngine.swift */, 246FD07126B9F86F00FD130B /* FullscreenMessage+Message.swift */, - 243B1B0128B411890074327E /* ItemData.swift */, 244E9583268262C7001DC957 /* Message.swift */, 244C2BDD26B36A4B008F086A /* Message+FullscreenMessageDelegate.swift */, 92315436261E3B36004AE7D3 /* Messaging.swift */, 244E954A267BAEBE001DC957 /* Messaging+EdgeEvents.swift */, 92315434261E3B36004AE7D3 /* Messaging+PublicAPI.swift */, - B6389A482A9B32D400B72FB4 /* PushTrackingStatus.swift */, + 0969D6372A79BB3C00A00BF7 /* Messaging+State.swift */, 92315437261E3B36004AE7D3 /* MessagingConstants.swift */, 244C2BD726B36480008F086A /* MessagingEdgeEventType.swift */, + 0969D63F2A7A9DC600A00BF7 /* MessagingMigrator.swift */, + 09B071EB2A651C7800F259C1 /* Proposition.swift */, + 09B071ED2A651CB200F259C1 /* PropositionItem.swift */, + 09F5D93F2B5CF35F00117437 /* PropositionInteraction.swift */, 2450594D2671283F00CC7CA0 /* MessagingRulesEngine.swift */, 2469A5E2274863F600E56457 /* MessagingRulesEngine+Caching.swift */, - 243B1AFF28B411630074327E /* PayloadItem.swift */, + 240FC42D2AA920D400AFEEEB /* ParsedPropositions.swift */, 243B1AFD28AEB1E60074327E /* PropositionInfo.swift */, 243B1AFB28AD7FCE0074327E /* PropositionPayload.swift */, + B6389A482A9B32D400B72FB4 /* PushTrackingStatus.swift */, + 090290C829DD11EA00388226 /* RuleConsequence+Messaging.swift */, 240F71FA26868F7100846587 /* SharedStateResult+Messaging.swift */, 244E9554267BB018001DC957 /* String+JSON.swift */, + 09B071E52A64D3D900F259C1 /* Surface.swift */, 241B2DD32821C80C00E4FF67 /* URL+QueryParams.swift */, 92315438261E3B36004AE7D3 /* Info.plist */, ); @@ -701,24 +943,37 @@ 9231543A261E3B36004AE7D3 /* UnitTests */ = { isa = PBXGroup; children = ( - 243EA6C82733258600195945 /* Dictionary+MergingTests.swift */, + 24CA4BBF2B6178B700D16369 /* schemas */, + 24CA4BCE2B617AA900D16369 /* Array+MessagingTests.swift */, + 24CA4BD02B617AC800D16369 /* Bundle+MessagingTests.swift */, + 24CA4BD22B617AED00D16369 /* Cache+MessagingTests.swift */, + 243EA6C82733258600195945 /* Dictionary+MessagingTests.swift */, 245059622673DBFD00CC7CA0 /* Event+MessagingTests.swift */, + 242920E22AC4D060000DB2CD /* FeedTests.swift */, + 244FEA4529B6A5D30058FA1C /* FeedItemTests.swift */, + 24CA4BD42B6195A500D16369 /* FeedRulesEngineTests.swift */, 243EA6CA273325A000195945 /* FullscreenMessage+MessageTests.swift */, - 248BD9CD28BD56CF00C49B94 /* ItemDataTests.swift */, 243EA6CC273325B700195945 /* MessageTests.swift */, 243EA6CE273325CC00195945 /* Message+FullscreenMessageDelegateTests.swift */, 245059642673DBFD00CC7CA0 /* MessagingTests.swift */, 243EA6D12733260000195945 /* Messaging+EdgeEventsTests.swift */, 245059632673DBFD00CC7CA0 /* Messaging+PublicApiTest.swift */, - B6E63D032A9EC71C007BB586 /* PushTrackingStatusTests.swift */, + 242920E82AC4EE2D000DB2CD /* Messaging+StateTests.swift */, 243EA6D32733261E00195945 /* MessagingEdgeEventTypeTests.swift */, + 24CA4BD82B6196DF00D16369 /* MessagingMigratorTests.swift */, + 24CA4BDA2B61971000D16369 /* PropositionTests.swift */, + 24CA4BDC2B61972500D16369 /* PropositionItemTests.swift */, + 24CA4BDE2B61974300D16369 /* PropositionInteractionTests.swift */, 2450596B2673DBFE00CC7CA0 /* MessagingRulesEngineTests.swift */, 2469A5E6274C0FA900E56457 /* MessagingRulesEngine+CachingTests.swift */, - 248BD9C728BD568400C49B94 /* PayloadItemTests.swift */, + 240FC42F2AAFB08E00AFEEEB /* ParsedPropositionsTests.swift */, 248BD9C928BD56A200C49B94 /* PropositionInfoTests.swift */, 248BD9CB28BD56B300C49B94 /* PropositionPayloadTests.swift */, + B6E63D032A9EC71C007BB586 /* PushTrackingStatusTests.swift */, + 242920D92ABCF488000DB2CD /* RuleConsequence+MessagingTests.swift */, 243EA6D52733263D00195945 /* SharedStateResult+MessagingTests.swift */, 243EA6D72733265400195945 /* String+JSONTests.swift */, + 24CA4BE02B61977800D16369 /* SurfaceTests.swift */, 241B2DD52821C99500E4FF67 /* URL+QueryParamsTests.swift */, 245059612673DBFD00CC7CA0 /* Info.plist */, ); @@ -730,21 +985,18 @@ children = ( 2469A5FA2759401900E56457 /* ConfigurationLoader.swift */, 928639FB263757A7000AFA53 /* Dictionary+Flatten.swift */, - 92FC58C7263688F0005BAE02 /* EventHub+Testable.swift */, 2469A5EA274D49B100E56457 /* JSONFileLoader.swift */, 2469A5E8274C107100E56457 /* MockCache.swift */, 9231543F261E3B36004AE7D3 /* MockExtension.swift */, + 090290CA29DE3F8200388226 /* MockFeedRulesEngine.swift */, 243EA6DD2739D4A400195945 /* MockFullscreenMessage.swift */, 2469A5DB27445CAF00E56457 /* MockLaunchRulesEngine.swift */, 243EA6DB2739D48900195945 /* MockMessage.swift */, 243EA6D92739D47400195945 /* MockMessaging.swift */, 243EA6E1273B436400195945 /* MockMessagingRulesEngine.swift */, - 9231543E261E3B36004AE7D3 /* MockNetworkService.swift */, 92FC594426372E34005BAE02 /* MockNotificationResponseCoder.swift */, - 9231543D261E3B36004AE7D3 /* TestableExtensionRuntime.swift */, 2402745B29FC424000884DFE /* TestableMessagingDelegate.swift */, 243EA6DF2739D9D700195945 /* TestableMessagingMobileParameters.swift */, - 928639D026374463000AFA53 /* TestableNetworkService.swift */, B631AE0F2A61205A00E8B82E /* CountDownLatch.swift */, B631AE122A6131BA00E8B82E /* FunctionalTestBase.swift */, B631AE1B2A613A1E00E8B82E /* FileManager+Test.swift .swift */, @@ -791,6 +1043,7 @@ 92BD7B0B251C0B7700C758CB /* Frameworks */ = { isa = PBXGroup; children = ( + 4C3B9F882AE3385B00A7D395 /* AEPTestUtils.framework */, 92BCEC9C2538FC0E0068F8B8 /* AEPEdge.framework */, 9249A4E8252294AD009193AB /* AEPCore.framework */, 9249A4EA252294AD009193AB /* AEPIdentity.framework */, @@ -805,6 +1058,7 @@ 28E46BC7A8F3939CF2DDDC8F /* Pods_MessagingDemoAppObjC.framework */, DB9F1A791A757B3C68D23B9D /* Pods_FunctionalTestApp.framework */, A44BD44CB3A7DA5EBB972652 /* Pods_E2EFunctionalTestApp.framework */, + 91A11E37A49E14EB847FB2DF /* Pods_MessagingDemoAppSwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -846,6 +1100,27 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ + 091881E32A16BAE200615481 /* MessagingDemoAppSwiftUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 091881F12A16BAE400615481 /* Build configuration list for PBXNativeTarget "MessagingDemoAppSwiftUI" */; + buildPhases = ( + B6617E5EAE3ECB145174A272 /* [CP] Check Pods Manifest.lock */, + 091881E02A16BAE200615481 /* Sources */, + 091881E12A16BAE200615481 /* Frameworks */, + 091881E22A16BAE200615481 /* Resources */, + B013C88BF0218262FBD0CDB5 /* [CP] Embed Pods Frameworks */, + 091881FD2A16D15100615481 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 091881F92A16D12E00615481 /* PBXTargetDependency */, + ); + name = MessagingDemoAppSwiftUI; + productName = MessagingDemoAppSwiftUI; + productReference = 091881E42A16BAE200615481 /* MessagingDemoAppSwiftUI.app */; + productType = "com.apple.product-type.application"; + }; 2414ED7E2899BA080036D505 /* MessagingDemoAppObjC */ = { isa = PBXNativeTarget; buildConfigurationList = 2414ED952899BA0A0036D505 /* Build configuration list for PBXNativeTarget "MessagingDemoAppObjC" */; @@ -903,6 +1178,7 @@ dependencies = ( 246EFA1327973E7400C76A6B /* PBXTargetDependency */, 24B071BC29072FAE00F4B18A /* PBXTargetDependency */, + B6D6AE942BA8E50600F0E975 /* PBXTargetDependency */, ); name = E2EFunctionalTests; productName = E2EFunctionalTests; @@ -985,6 +1261,7 @@ ); dependencies = ( 9296BA882537BFC0002C88F7 /* PBXTargetDependency */, + B6D6AE922BA8C48100F0E975 /* PBXTargetDependency */, ); name = UnitTests; productName = UnitTests; @@ -1035,9 +1312,12 @@ 922FFCE7251B2BBA00BCE010 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1400; LastUpgradeCheck = 1420; TargetAttributes = { + 091881E32A16BAE200615481 = { + CreatedOnToolsVersion = 14.0; + }; 2414ED7E2899BA080036D505 = { CreatedOnToolsVersion = 13.4.1; }; @@ -1046,7 +1326,7 @@ }; 246EFA0C27973E7400C76A6B = { CreatedOnToolsVersion = 13.2.1; - TestTargetID = 24B071A429072E9800F4B18A; + TestTargetID = 2469A6012759999E00E56457; }; 24B071A429072E9800F4B18A = { CreatedOnToolsVersion = 14.0.1; @@ -1062,6 +1342,7 @@ }; 9296BA802537BFC0002C88F7 = { CreatedOnToolsVersion = 12.0.1; + TestTargetID = 2469A6012759999E00E56457; }; 92FC58772636840C005BAE02 = { CreatedOnToolsVersion = 12.4; @@ -1092,6 +1373,7 @@ 92FC58772636840C005BAE02 /* FunctionalTests */, 2469A6012759999E00E56457 /* FunctionalTestApp */, 92085981251BEB7100B9C65A /* MessagingDemoApp */, + 091881E32A16BAE200615481 /* MessagingDemoAppSwiftUI */, 2414ED7E2899BA080036D505 /* MessagingDemoAppObjC */, 9296BA802537BFC0002C88F7 /* UnitTests */, B6165DA229A67AD90031B84D /* NotificationService */, @@ -1100,6 +1382,15 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 091881E22A16BAE200615481 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0969D6D12A7AFB3800A00BF7 /* Preview Content in Resources */, + 091881EB2A16BAE400615481 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2414ED7D2899BA080036D505 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1162,10 +1453,22 @@ buildActionMask = 2147483647; files = ( 2469A5DF274465C900E56457 /* showOnceRule.json in Resources */, + 240FC4362AAFCBE500AFEEEB /* feedProposition.json in Resources */, + 242920EB2AC5D745000DB2CD /* cachedProposition.json in Resources */, + 242920D42ABCA559000DB2CD /* ruleWithNoConsequence.json in Resources */, + 09265D622B74497E0085D825 /* codeBasedPropositionJsonContent.json in Resources */, + 240FC43F2AB0B8A300AFEEEB /* feedPropositionContent.json in Resources */, 2438B92C29C10B2D001D6F3A /* wrongScopeRule.json in Resources */, + 240FC4422AB0B8A300AFEEEB /* inappPropositionV2Content.json in Resources */, 2438B93029C12179001D6F3A /* malformedContentRule.json in Resources */, + 240FC4382AAFCE3400AFEEEB /* inappPropositionV2.json in Resources */, 2438B92F29C12179001D6F3A /* emptyContentStringRule.json in Resources */, + 240BDFF72B72F10000AE8547 /* mockPropositionItem.json in Resources */, + 242920D62ABCD8A6000DB2CD /* ruleWithUnknownConsequenceSchema.json in Resources */, + 09265D602B74497E0085D825 /* codeBasedPropositionHtml.json in Resources */, + 09265D612B74497E0085D825 /* codeBasedPropositionHtmlContent.json in Resources */, 2469A5E12744696400E56457 /* eventSequenceRule.json in Resources */, + 09265D632B74497E0085D825 /* codeBasedPropositionJson.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1343,6 +1646,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + B013C88BF0218262FBD0CDB5 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MessagingDemoAppSwiftUI/Pods-MessagingDemoAppSwiftUI-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MessagingDemoAppSwiftUI/Pods-MessagingDemoAppSwiftUI-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MessagingDemoAppSwiftUI/Pods-MessagingDemoAppSwiftUI-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; B1773D3E2D3AA31B70FD592C /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1365,6 +1685,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + B6617E5EAE3ECB145174A272 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-MessagingDemoAppSwiftUI-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; BAC36E387E552FEB07FD5A81 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1485,6 +1827,24 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 091881E02A16BAE200615481 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 091881F72A16C2D600615481 /* FeedsView.swift in Sources */, + 0969D6342A75D55E00A00BF7 /* CustomImageView.swift in Sources */, + 094C4E9E2A74FC4200D99C70 /* FeedItemView.swift in Sources */, + 091881E92A16BAE300615481 /* HomeView.swift in Sources */, + 092A77F22A757CB40026D325 /* CodeBasedOffersView.swift in Sources */, + 0969D63C2A7A0EF600A00BF7 /* CustomTextView.swift in Sources */, + 096E19922A758D1600D4EBCF /* FeedItemDetailView.swift in Sources */, + 091881FF2A16D7A200615481 /* PushView.swift in Sources */, + 091881F42A16C2A200615481 /* InAppView.swift in Sources */, + 0969D6362A760AF900A00BF7 /* CustomHtmlView.swift in Sources */, + 091881E72A16BAE300615481 /* MessagingDemoAppSwiftUIApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2414ED7B2899BA080036D505 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1511,7 +1871,6 @@ buildActionMask = 2147483647; files = ( 246EFA1A2797441600C76A6B /* Dictionary+Flatten.swift in Sources */, - 246EFA1B2797441600C76A6B /* EventHub+Testable.swift in Sources */, 246EFA1C2797441600C76A6B /* JSONFileLoader.swift in Sources */, 246EFA1D2797441600C76A6B /* MockCache.swift in Sources */, 246EFA1E2797441600C76A6B /* MockExtension.swift in Sources */, @@ -1521,12 +1880,9 @@ 246EFA222797441600C76A6B /* MockMessaging.swift in Sources */, 2402745C29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */, 246EFA232797441600C76A6B /* MockMessagingRulesEngine.swift in Sources */, - 246EFA242797441600C76A6B /* MockNetworkService.swift in Sources */, 246EFA252797441600C76A6B /* MockNotificationResponseCoder.swift in Sources */, - 246EFA262797441600C76A6B /* TestableExtensionRuntime.swift in Sources */, 24552E69291F08CF000744AD /* Environment.swift in Sources */, 246EFA272797441600C76A6B /* TestableMessagingMobileParameters.swift in Sources */, - 246EFA282797441600C76A6B /* TestableNetworkService.swift in Sources */, 246EFA1027973E7400C76A6B /* E2EFunctionalTests.swift in Sources */, 246EFA19279743EB00C76A6B /* ConfigurationLoader.swift in Sources */, ); @@ -1557,25 +1913,44 @@ buildActionMask = 2147483647; files = ( 244E9555267BB018001DC957 /* String+JSON.swift in Sources */, + 242920F12AD061F3000DB2CD /* FeedItemSchemaData.swift in Sources */, + 09B071E62A64D3D900F259C1 /* Surface.swift in Sources */, + 240FC42E2AA920D400AFEEEB /* ParsedPropositions.swift in Sources */, 245059982673F94D00CC7CA0 /* MessagingConstants.swift in Sources */, + 09B071EE2A651CB200F259C1 /* PropositionItem.swift in Sources */, 246FD07226B9F86F00FD130B /* FullscreenMessage+Message.swift in Sources */, - 243B1B0228B411890074327E /* ItemData.swift in Sources */, + 242920ED2ACF7760000DB2CD /* SchemaType.swift in Sources */, + 09B071EC2A651C7800F259C1 /* Proposition.swift in Sources */, 243B1AFC28AD7FCE0074327E /* PropositionPayload.swift in Sources */, + 24569A8F2AE2CD6E00FC356F /* ContentType.swift in Sources */, 243B1AFE28AEB1E60074327E /* PropositionInfo.swift in Sources */, 240F71FB26868F7100846587 /* SharedStateResult+Messaging.swift in Sources */, 2469A5E3274863F600E56457 /* MessagingRulesEngine+Caching.swift in Sources */, + 242920F32AD06207000DB2CD /* JsonContentSchemaData.swift in Sources */, + 090290C529DCED0B00388226 /* FeedRulesEngine.swift in Sources */, + 242920F52AD06215000DB2CD /* HtmlContentSchemaData.swift in Sources */, + 244FEA4429B6A1060058FA1C /* FeedItem.swift in Sources */, 244E954B267BAEBE001DC957 /* Messaging+EdgeEvents.swift in Sources */, + 09B071F62A7318CB00F259C1 /* Array+Messaging.swift in Sources */, + 244FEA4829B8E2950058FA1C /* Feed.swift in Sources */, + 240316B82A83DDD80016B0D9 /* Cache+Messaging.swift in Sources */, 245059522671283F00CC7CA0 /* MessagingRulesEngine.swift in Sources */, 244C2BD826B36480008F086A /* MessagingEdgeEventType.swift in Sources */, 2450599D2673FABF00CC7CA0 /* Messaging+PublicAPI.swift in Sources */, + 09B071E82A64D80E00F259C1 /* Bundle+Messaging.swift in Sources */, 245059A72673FAC700CC7CA0 /* Event+Messaging.swift in Sources */, 244E9584268262C8001DC957 /* Message.swift in Sources */, - 244E955B267BB253001DC957 /* Dictionary+Merging.swift in Sources */, + 244E955B267BB253001DC957 /* Dictionary+Messaging.swift in Sources */, + 0969D6402A7A9DC600A00BF7 /* MessagingMigrator.swift in Sources */, + 090290C929DD11EA00388226 /* RuleConsequence+Messaging.swift in Sources */, + 242920EF2AD05AFA000DB2CD /* InAppSchemaData.swift in Sources */, 244C2BDE26B36A4B008F086A /* Message+FullscreenMessageDelegate.swift in Sources */, - 243B1B0028B411630074327E /* PayloadItem.swift in Sources */, + 0969D6382A79BB3C00A00BF7 /* Messaging+State.swift in Sources */, + 242920F82AD06791000DB2CD /* RulesetSchemaData.swift in Sources */, 241B2DD42821C80C00E4FF67 /* URL+QueryParams.swift in Sources */, 245059A22673FAC200CC7CA0 /* Messaging.swift in Sources */, B6389A492A9B32D400B72FB4 /* PushTrackingStatus.swift in Sources */, + 09F5D9402B5CF35F00117437 /* PropositionInteraction.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1583,42 +1958,58 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 24CA4BCD2B617A8100D16369 /* SchemaTypeTests.swift in Sources */, 243EA6D82733265400195945 /* String+JSONTests.swift in Sources */, + 090290CB29DE3F8200388226 /* MockFeedRulesEngine.swift in Sources */, + 242920E92AC4EE2D000DB2CD /* Messaging+StateTests.swift in Sources */, B6D6A02B265FB1FA005042BE /* Dictionary+Flatten.swift in Sources */, - 92315465261E3B72004AE7D3 /* MockNetworkService.swift in Sources */, 2469A5E9274C107100E56457 /* MockCache.swift in Sources */, + 242920DA2ABCF488000DB2CD /* RuleConsequence+MessagingTests.swift in Sources */, 92FC594626372E34005BAE02 /* MockNotificationResponseCoder.swift in Sources */, 241B2DD62821C99500E4FF67 /* URL+QueryParamsTests.swift in Sources */, - 9231545E261E3B6F004AE7D3 /* TestableExtensionRuntime.swift in Sources */, 243EA6E2273B436400195945 /* MockMessagingRulesEngine.swift in Sources */, 245059702673DBFE00CC7CA0 /* Messaging+PublicApiTest.swift in Sources */, 243EA6D0273325DC00195945 /* MessageTests.swift in Sources */, + 244FEA4629B6A5D30058FA1C /* FeedItemTests.swift in Sources */, + 24CA4BC12B6178EE00D16369 /* InAppSchemaDataTests.swift in Sources */, + 24CA4BC72B617A2500D16369 /* HtmlContentSchemaDataTests.swift in Sources */, B6E63D042A9EC71C007BB586 /* PushTrackingStatusTests.swift in Sources */, - 92FC58C9263688F0005BAE02 /* EventHub+Testable.swift in Sources */, - 243EA6C92733258600195945 /* Dictionary+MergingTests.swift in Sources */, + 243EA6C92733258600195945 /* Dictionary+MessagingTests.swift in Sources */, 2450596F2673DBFE00CC7CA0 /* Event+MessagingTests.swift in Sources */, 243EA6DA2739D47500195945 /* MockMessaging.swift in Sources */, 243EA6D42733261E00195945 /* MessagingEdgeEventTypeTests.swift in Sources */, + 24CA4BD32B617AED00D16369 /* Cache+MessagingTests.swift in Sources */, B631AE112A612DA000E8B82E /* CountDownLatch.swift in Sources */, 2402745E29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */, 243EA6CF273325CC00195945 /* Message+FullscreenMessageDelegateTests.swift in Sources */, 245059712673DBFE00CC7CA0 /* MessagingTests.swift in Sources */, 2469A5EB274D49B100E56457 /* JSONFileLoader.swift in Sources */, - 248BD9CE28BD56CF00C49B94 /* ItemDataTests.swift in Sources */, + 24CA4BDB2B61971000D16369 /* PropositionTests.swift in Sources */, + 24CA4BDF2B61974300D16369 /* PropositionInteractionTests.swift in Sources */, + 24CA4BC92B617A3F00D16369 /* JsonContentSchemaDataTests.swift in Sources */, + 24CA4BDD2B61972500D16369 /* PropositionItemTests.swift in Sources */, 245059762673DBFE00CC7CA0 /* MessagingRulesEngineTests.swift in Sources */, + 24CA4BD52B6195A500D16369 /* FeedRulesEngineTests.swift in Sources */, + 24CA4BE12B61977800D16369 /* SurfaceTests.swift in Sources */, + 24CA4BD12B617AC800D16369 /* Bundle+MessagingTests.swift in Sources */, 243EA6E02739D9D700195945 /* TestableMessagingMobileParameters.swift in Sources */, + 24CA4BCB2B617A7100D16369 /* RulesetSchemaDataTests.swift in Sources */, + 242920E42AC4D060000DB2CD /* FeedTests.swift in Sources */, 243EA6D22733260000195945 /* Messaging+EdgeEventsTests.swift in Sources */, 243EA6D62733263D00195945 /* SharedStateResult+MessagingTests.swift in Sources */, + 24CA4BC52B617A1000D16369 /* FeedItemSchemaDataTests.swift in Sources */, 243EA6DE2739D4A400195945 /* MockFullscreenMessage.swift in Sources */, - 928639D226374463000AFA53 /* TestableNetworkService.swift in Sources */, + 24CA4BCF2B617AA900D16369 /* Array+MessagingTests.swift in Sources */, 2469A5DC27445CAF00E56457 /* MockLaunchRulesEngine.swift in Sources */, 2469A5E7274C0FA900E56457 /* MessagingRulesEngine+CachingTests.swift in Sources */, + 24CA4BC32B6179F800D16369 /* ContentTypeTests.swift in Sources */, 243EA6CB273325A000195945 /* FullscreenMessage+MessageTests.swift in Sources */, 248BD9CC28BD56B300C49B94 /* PropositionPayloadTests.swift in Sources */, 9231546C261E3B75004AE7D3 /* MockExtension.swift in Sources */, 248BD9CA28BD56A200C49B94 /* PropositionInfoTests.swift in Sources */, + 240FC4302AAFB08E00AFEEEB /* ParsedPropositionsTests.swift in Sources */, 243EA6DC2739D48900195945 /* MockMessage.swift in Sources */, - 248BD9C828BD568400C49B94 /* PayloadItemTests.swift in Sources */, + 24CA4BD92B6196DF00D16369 /* MessagingMigratorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1626,17 +2017,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 92FC58D326368900005BAE02 /* MockNetworkService.swift in Sources */, + 090290C329D4EA9F00388226 /* MockCache.swift in Sources */, + 928639FC263757A7000AFA53 /* Dictionary+Flatten.swift in Sources */, + 2402745D29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */, + 24EE301E28FF61F0005E417C /* InAppMessagingEventTests.swift in Sources */, B631AE182A61374500E8B82E /* IntrumentedExtension.swift in Sources */, B631AE132A6131BA00E8B82E /* FunctionalTestBase.swift in Sources */, - 92FC58CE263688FD005BAE02 /* TestableExtensionRuntime.swift in Sources */, 928639FC263757A7000AFA53 /* Dictionary+Flatten.swift in Sources */, 2402745D29FC424000884DFE /* TestableMessagingDelegate.swift in Sources */, 24EE301E28FF61F0005E417C /* InAppMessagingEventTests.swift in Sources */, - 92FC58C8263688F0005BAE02 /* EventHub+Testable.swift in Sources */, B631AE1A2A6139CE00E8B82E /* UserDefaults+Test.swift .swift in Sources */, B631AE102A61205A00E8B82E /* CountDownLatch.swift in Sources */, - 928639D126374463000AFA53 /* TestableNetworkService.swift in Sources */, 92FC594526372E34005BAE02 /* MockNotificationResponseCoder.swift in Sources */, 92FC587B2636840C005BAE02 /* MessagingPublicAPITests.swift in Sources */, 2469A5FB2759401900E56457 /* ConfigurationLoader.swift in Sources */, @@ -1658,6 +2049,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 091881F92A16D12E00615481 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 925DF4A425227C4700A5DE31 /* AEPMessaging */; + targetProxy = 091881F82A16D12E00615481 /* PBXContainerItemProxy */; + }; 2414ED9B2899BAC50036D505 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -1715,6 +2111,16 @@ target = B6165DA229A67AD90031B84D /* NotificationService */; targetProxy = B6165DA829A67ADA0031B84D /* PBXContainerItemProxy */; }; + B6D6AE922BA8C48100F0E975 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2469A6012759999E00E56457 /* FunctionalTestApp */; + targetProxy = B6D6AE912BA8C48100F0E975 /* PBXContainerItemProxy */; + }; + B6D6AE942BA8E50600F0E975 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2469A6012759999E00E56457 /* FunctionalTestApp */; + targetProxy = B6D6AE932BA8E50600F0E975 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1785,6 +2191,72 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 091881EF2A16BAE400615481 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7BB455BF61D9D261AC490547 /* Pods-MessagingDemoAppSwiftUI.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"TestApps/MessagingDemoAppSwiftUI/Preview Content\""; + DEVELOPMENT_TEAM = FKGEE875K4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "MessagingDemoAppSwiftUI-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.MessagingDemoAppSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 091881F02A16BAE400615481 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FF12209B1A62AAB46BE0E834 /* Pods-MessagingDemoAppSwiftUI.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"TestApps/MessagingDemoAppSwiftUI/Preview Content\""; + DEVELOPMENT_TEAM = FKGEE875K4; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "MessagingDemoAppSwiftUI-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.MessagingDemoAppSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 2414ED962899BA0A0036D505 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = A9ADDB0B1BD3178D864C6041 /* Pods-MessagingDemoAppObjC.debug.xcconfig */; @@ -1925,7 +2397,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/E2EFunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/E2EFunctionalTestApp"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FunctionalTestApp"; }; name = Debug; }; @@ -1945,7 +2417,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/E2EFunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/E2EFunctionalTestApp"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FunctionalTestApp"; }; name = Release; }; @@ -2110,7 +2582,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2165,7 +2637,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2196,7 +2668,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 4.1.1; + MARKETING_VERSION = 5.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.messaging; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2231,7 +2703,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 4.1.1; + MARKETING_VERSION = 5.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.messaging; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2304,6 +2776,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FunctionalTestApp"; }; name = Debug; }; @@ -2346,6 +2819,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FunctionalTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/FunctionalTestApp"; }; name = Release; }; @@ -2492,6 +2966,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 091881F12A16BAE400615481 /* Build configuration list for PBXNativeTarget "MessagingDemoAppSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 091881EF2A16BAE400615481 /* Debug */, + 091881F02A16BAE400615481 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 2414ED952899BA0A0036D505 /* Build configuration list for PBXNativeTarget "MessagingDemoAppObjC" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/AEPMessaging.xcodeproj/xcshareddata/xcschemes/MessagingDemoAppSwiftUI.xcscheme b/AEPMessaging.xcodeproj/xcshareddata/xcschemes/MessagingDemoAppSwiftUI.xcscheme new file mode 100644 index 00000000..afb21d07 --- /dev/null +++ b/AEPMessaging.xcodeproj/xcshareddata/xcschemes/MessagingDemoAppSwiftUI.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPMessaging/Sources/Array+Messaging.swift b/AEPMessaging/Sources/Array+Messaging.swift new file mode 100644 index 00000000..a669bceb --- /dev/null +++ b/AEPMessaging/Sources/Array+Messaging.swift @@ -0,0 +1,40 @@ +/* + Copyright 2023 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 Foundation + +// MARK: Array extension + +extension Array { + func toDictionary(_ transform: (Element) throws -> Key) rethrows -> [Key: [Element]] { + var dictionary = [Key: [Element]]() + + for element in self { + let transformedElement = try transform(element) + if var existingEntry = dictionary[transformedElement] { + existingEntry.append(element) + dictionary[transformedElement] = existingEntry + } else { + dictionary[transformedElement] = [element] + } + } + return dictionary + } +} + +extension Array where Element: Hashable { + func minus(_ other: [Element]) -> [Element] { + let completeSet = Set(self) + let subset = Set(other) + return Array(completeSet.subtracting(subset)) as [Element] + } +} diff --git a/AEPMessaging/Tests/TestHelpers/MockNetworkService.swift b/AEPMessaging/Sources/Bundle+Messaging.swift similarity index 63% rename from AEPMessaging/Tests/TestHelpers/MockNetworkService.swift rename to AEPMessaging/Sources/Bundle+Messaging.swift index d779aa7a..82ec8e80 100644 --- a/AEPMessaging/Tests/TestHelpers/MockNetworkService.swift +++ b/AEPMessaging/Sources/Bundle+Messaging.swift @@ -1,5 +1,5 @@ /* - Copyright 2021 Adobe. All rights reserved. + Copyright 2023 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 @@ -10,14 +10,15 @@ governing permissions and limitations under the License. */ -@testable import AEPCore -import AEPServices import Foundation -class MockNetworkService: Networking { - var actualNetworkRequest: NetworkRequest? +// MARK: Bundle extension - func connectAsync(networkRequest: NetworkRequest, completionHandler _: ((HttpConnection) -> Void)?) { - actualNetworkRequest = networkRequest +extension Bundle { + var mobileappSurface: String { + guard let bundleIdentifier = Self.main.bundleIdentifier, !bundleIdentifier.isEmpty else { + return "unknown" + } + return MessagingConstants.XDM.Inbound.SURFACE_BASE + bundleIdentifier } } diff --git a/AEPMessaging/Sources/Cache+Messaging.swift b/AEPMessaging/Sources/Cache+Messaging.swift new file mode 100644 index 00000000..d18d013e --- /dev/null +++ b/AEPMessaging/Sources/Cache+Messaging.swift @@ -0,0 +1,75 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +extension Cache { + // MARK: - getters + + var propositions: [Surface: [Proposition]]? { + guard let cachedPropositions = get(key: MessagingConstants.Caches.PROPOSITIONS) else { + Log.trace(label: MessagingConstants.LOG_TAG, "Unable to load cached messages, cache file not found.") + return nil + } + + let decoder = JSONDecoder() + guard let propositionsDict: [String: [Proposition]] = try? decoder.decode([String: [Proposition]].self, from: cachedPropositions.data) else { + Log.debug(label: MessagingConstants.LOG_TAG, "No message definitions found in cache.") + return nil + } + + var retrievedPropositions: [Surface: [Proposition]] = [:] + for (key, value) in propositionsDict { + retrievedPropositions[Surface(uri: key)] = value + } + return retrievedPropositions + } + + // MARK: setters + + // update entries for surfaces already existing + // remove surfaces listed by `surfaces` + // write or remove cache file based on result + func updatePropositions(_ newPropositions: [Surface: [Proposition]]?, removing surfaces: [Surface]? = nil) { + let existingPropositions = propositions ?? [:] + var updatedPropositions = existingPropositions.merging(newPropositions ?? [:]) { _, new in new } + if let surfaces = surfaces { + updatedPropositions = updatedPropositions.filter { + !surfaces.contains($0.key) + } + } + + guard !updatedPropositions.isEmpty else { + try? remove(key: MessagingConstants.Caches.PROPOSITIONS) + return + } + + var propositionsToCache: [String: [Proposition]] = [:] + for (key, value) in updatedPropositions { + propositionsToCache[key.uri] = value + } + + let encoder = JSONEncoder() + guard let cacheData = try? encoder.encode(propositionsToCache) else { + Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache, unable to encode proposition.") + return + } + let cacheEntry = CacheEntry(data: cacheData, expiry: .never, metadata: nil) + do { + try set(key: MessagingConstants.Caches.PROPOSITIONS, entry: cacheEntry) + Log.trace(label: MessagingConstants.LOG_TAG, "In-app messaging cache has been created.") + } catch { + Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache: \(error).") + } + } +} diff --git a/AEPMessaging/Sources/Dictionary+Merging.swift b/AEPMessaging/Sources/Dictionary+Messaging.swift similarity index 65% rename from AEPMessaging/Sources/Dictionary+Merging.swift rename to AEPMessaging/Sources/Dictionary+Messaging.swift index 65a59f5a..9fd51c3b 100644 --- a/AEPMessaging/Sources/Dictionary+Merging.swift +++ b/AEPMessaging/Sources/Dictionary+Messaging.swift @@ -10,6 +10,7 @@ governing permissions and limitations under the License. */ +import AEPCore import Foundation extension Dictionary where Key == String, Value == Any { @@ -19,3 +20,16 @@ extension Dictionary where Key == String, Value == Any { merge(rhs) { _, new in new } } } + +extension Dictionary { + mutating func addArray(_ sequence: S, forKey key: Key) where Value == [S.Element] { + guard let value = sequence as? [S.Element], !value.isEmpty else { + return + } + self[key] == nil ? self[key] = value : self[key]?.append(contentsOf: sequence) + } + + mutating func add(_ element: T, forKey key: Key) where Value == [T] { + self[key] == nil ? self[key] = [element] : self[key]?.append(element) + } +} diff --git a/AEPMessaging/Sources/Event+Messaging.swift b/AEPMessaging/Sources/Event+Messaging.swift index cdb6e2de..c06cc114 100644 --- a/AEPMessaging/Sources/Event+Messaging.swift +++ b/AEPMessaging/Sources/Event+Messaging.swift @@ -18,165 +18,8 @@ import Foundation extension Event { // MARK: - In-app Message Consequence Event Handling - var isInAppMessage: Bool { - consequenceType == MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE - } - - // MARK: - In-app Message Properties - - /// Grabs the messageExecutionID value from XDM - var messageId: String? { - consequence?[MessagingConstants.Event.Data.Key.IAM.ID] as? String - } - - var template: String? { - details?[MessagingConstants.Event.Data.Key.IAM.TEMPLATE] as? String - } - - var html: String? { - details?[MessagingConstants.Event.Data.Key.IAM.HTML] as? String - } - - var remoteAssets: [String]? { - details?[MessagingConstants.Event.Data.Key.IAM.REMOTE_ASSETS] as? [String] - } - - /// sample `mobileParameters` json which gets represented by a `MessageSettings` object: - /// { - /// "mobileParameters": { - /// "schemaVersion": "1.0", - /// "width": 80, - /// "height": 50, - /// "verticalAlign": "center", - /// "verticalInset": 0, - /// "horizontalAlign": "center", - /// "horizontalInset": 0, - /// "uiTakeover": true, - /// "displayAnimation": "top", - /// "dismissAnimation": "top", - /// "backdropColor": "000000", // RRGGBB - /// "backdropOpacity: 0.3, - /// "cornerRadius": 15, - /// "gestures": { - /// "swipeUp": "adbinapp://dismiss", - /// "swipeDown": "adbinapp://dismiss", - /// "swipeLeft": "adbinapp://dismiss?interaction=negative", - /// "swipeRight": "adbinapp://dismiss?interaction=positive", - /// "tapBackground": "adbinapp://dismiss" - /// } - /// } - /// } - func getMessageSettings(withParent parent: Any?) -> MessageSettings { - let cornerRadius = CGFloat(messageCornerRadius ?? 0) - let settings = MessageSettings(parent: parent) - .setWidth(messageWidth) - .setHeight(messageHeight) - .setVerticalAlign(messageVAlign) - .setVerticalInset(messageVInset) - .setHorizontalAlign(messageHAlign) - .setHorizontalInset(messageHInset) - .setUiTakeover(messageUiTakeover) - .setBackdropColor(messageBackdropColor) - .setBackdropOpacity(messageBackdropOpacity) - .setCornerRadius(messageCornerRadius != nil ? cornerRadius : nil) - .setDisplayAnimation(messageDisplayAnimation) - .setDismissAnimation(messageDismissAnimation) - .setGestures(messageGestures) - return settings - } - - // MARK: Private - - private var mobileParametersDictionary: [String: Any]? { - details?[MessagingConstants.Event.Data.Key.IAM.MOBILE_PARAMETERS] as? [String: Any] - } - - private var messageWidth: Int? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.WIDTH] as? Int - } - - private var messageHeight: Int? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.HEIGHT] as? Int - } - - private var messageVAlign: MessageAlignment { - if let alignmentString = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.VERTICAL_ALIGN] as? String { - return MessageAlignment.fromString(alignmentString) - } - return .center - } - - private var messageVInset: Int? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.VERTICAL_INSET] as? Int - } - - private var messageHAlign: MessageAlignment { - if let alignmentString = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.HORIZONTAL_ALIGN] as? String { - return MessageAlignment.fromString(alignmentString) - } - return .center - } - - private var messageHInset: Int? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.HORIZONTAL_INSET] as? Int - } - - private var messageUiTakeover: Bool { - if let takeover = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.UI_TAKEOVER] as? Bool { - return takeover - } - return true - } - - private var messageBackdropColor: String? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.BACKDROP_COLOR] as? String - } - - private var messageBackdropOpacity: CGFloat? { - if let opacity = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.BACKDROP_OPACITY] as? Double { - return CGFloat(opacity) - } - return nil - } - - private var messageCornerRadius: Int? { - mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.CORNER_RADIUS] as? Int - } - - private var messageDisplayAnimation: MessageAnimation { - if let animate = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.DISPLAY_ANIMATION] as? String { - return MessageAnimation.fromString(animate) - } - return .none - } - - private var messageDismissAnimation: MessageAnimation { - if let animate = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.DISMISS_ANIMATION] as? String { - return MessageAnimation.fromString(animate) - } - return .none - } - - private var messageGestures: [MessageGesture: URL]? { - if let gesturesJson = mobileParametersDictionary?[MessagingConstants.Event.Data.Key.IAM.GESTURES] as? [String: String] { - var gestures: [MessageGesture: URL] = [:] - for gesture in gesturesJson { - if let gestureEnum = MessageGesture.fromString(gesture.key), let url = URL(string: gesture.value) { - gestures[gestureEnum] = url - } - } - return gestures.isEmpty ? nil : gestures - } - return nil - } - - // MARK: - Message Object Validation - - var containsValidInAppMessage: Bool { - // remoteAssets are always optional. - // template is currently optional as it's not being used, - // but may be used later if new kinds of messages are introduced - html != nil + var isSchemaConsequence: Bool { + consequenceType == MessagingConstants.ConsequenceTypes.SCHEMA } // MARK: - Consequence EventData Processing @@ -200,24 +43,24 @@ extension Event { } var requestEventId: String? { - data?[MessagingConstants.Event.Data.Key.REQUEST_EVENT_ID] as? String + parentID?.uuidString as? String ?? data?[MessagingConstants.Event.Data.Key.REQUEST_EVENT_ID] as? String } - /// payload is an array of `PropositionPayload` objects, each containing an in-app message and related tracking information - var payload: [PropositionPayload]? { + /// payload is an array of `Proposition` objects, each containing inbound content and related tracking information + var payload: [Proposition]? { guard let payloadMap = data?[MessagingConstants.Event.Data.Key.Personalization.PAYLOAD] as? [[String: Any]] else { return nil } - var returnablePayloads: [PropositionPayload] = [] + + var returnablePayloads: [Proposition] = [] let encoder = JSONEncoder() let decoder = JSONDecoder() for thisPayloadAny in payloadMap { if let thisPayload = AnyCodable.from(dictionary: thisPayloadAny), - let payloadData = try? encoder.encode(thisPayload) - { + let payloadData = try? encoder.encode(thisPayload) { do { - let payloadObject = try decoder.decode(PropositionPayload.self, from: payloadData) + let payloadObject = try decoder.decode(Proposition.self, from: payloadData) returnablePayloads.append(payloadObject) } catch { Log.warning(label: MessagingConstants.LOG_TAG, "Failed to decode an invalid personalization response: \(error)") @@ -228,7 +71,7 @@ extension Event { } var scope: String? { - payload?.first?.propositionInfo.scope + payload?.first?.scope } // MARK: Private @@ -248,7 +91,7 @@ extension Event { } private var isMessagingType: Bool { - type == MessagingConstants.Event.EventType.messaging + type == EventType.messaging } private var isRequestContentSource: Bool { @@ -259,6 +102,75 @@ extension Event { data?[MessagingConstants.Event.Data.Key.REFRESH_MESSAGES] as? Bool ?? false } + // MARK: - Update Propositions Public API Event + + var isUpdatePropositionsEvent: Bool { + isMessagingType && isRequestContentSource && updatePropositions + } + + var surfaces: [Surface]? { + guard + let surfacesData = data?[MessagingConstants.Event.Data.Key.SURFACES] as? [[String: Any]], + let jsonData = try? JSONSerialization.data(withJSONObject: surfacesData) + else { + return nil + } + + return try? JSONDecoder().decode([Surface].self, from: jsonData) + } + + private var updatePropositions: Bool { + data?[MessagingConstants.Event.Data.Key.UPDATE_PROPOSITIONS] as? Bool ?? false + } + + // MARK: - Track Propositions Public API event + + var isTrackPropositionsEvent: Bool { + isMessagingType && isRequestContentSource && trackPropositions + } + + var propositionInteractionXdm: [String: Any]? { + guard + let propositionInteractionXdm = data?[MessagingConstants.Event.Data.Key.PROPOSITION_INTERACTION] as? [String: Any], + !propositionInteractionXdm.isEmpty + else { + return nil + } + return propositionInteractionXdm + } + + private var trackPropositions: Bool { + data?[MessagingConstants.Event.Data.Key.TRACK_PROPOSITIONS] as? Bool ?? false + } + + // MARK: - Get propositions public API event + + var isGetPropositionsEvent: Bool { + isMessagingType && isRequestContentSource && getPropositions + } + + private var getPropositions: Bool { + data?[MessagingConstants.Event.Data.Key.GET_PROPOSITIONS] as? Bool ?? false + } + + var propositions: [Proposition]? { + guard + let propositionsData = data?[MessagingConstants.Event.Data.Key.PROPOSITIONS] as? [[String: Any]], + let jsonData = try? JSONSerialization.data(withJSONObject: propositionsData) + else { + return nil + } + + return try? JSONDecoder().decode([Proposition].self, from: jsonData) + } + + var responseError: AEPError? { + guard let errorInt = data?[MessagingConstants.Event.Data.Key.RESPONSE_ERROR] as? Int else { + return nil + } + return AEPError(rawValue: errorInt) + } + // MARK: - SetPushIdentifier Event var isGenericIdentityRequestContentEvent: Bool { @@ -269,10 +181,24 @@ extension Event { data?[MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER] as? String } - // MARK: - Push Clickthrough Event + // MARK: - Push tracking + + var pushTrackingStatus: PushTrackingStatus? { + guard let statusInt = data?[MessagingConstants.Event.Data.Key.PUSH_NOTIFICATION_TRACKING_STATUS] as? Int else { + return nil + } + return PushTrackingStatus(fromRawValue: statusInt) + } + + var pushClickThroughUrl: URL? { + guard let link = data?[MessagingConstants.Event.Data.Key.PUSH_CLICK_THROUGH_URL] as? String else { + return nil + } + return URL(string: link) + } var isMessagingRequestContentEvent: Bool { - type == MessagingConstants.Event.EventType.messaging && source == EventSource.requestContent + type == EventType.messaging && source == EventSource.requestContent } var xdmEventType: String? { @@ -280,7 +206,7 @@ extension Event { } var messagingId: String? { - data?[MessagingConstants.Event.Data.Key.MESSAGE_ID] as? String + data?[MessagingConstants.Event.Data.Key.ID] as? String } var actionId: String? { @@ -303,17 +229,35 @@ extension Event { data?[MessagingConstants.XDM.Key.ADOBE_XDM] as? [String: Any] } - var pushTrackingStatus: PushTrackingStatus? { - guard let statusInt = data?[MessagingConstants.Event.Data.Key.PUSH_NOTIFICATION_TRACKING_STATUS] as? Int else { - return nil - } - return PushTrackingStatus(fromRawValue: statusInt) + // MARK: - Error response Event + + /// Creates a response event with specified AEPError type added in the Event data. + /// - Parameter error: type of AEPError + /// - Returns: error response Event + func createErrorResponseEvent(_ error: AEPError) -> Event { + createResponseEvent(name: MessagingConstants.Event.Name.MESSAGE_PROPOSITIONS_RESPONSE, + type: EventType.messaging, + source: EventSource.responseContent, + data: [ + MessagingConstants.Event.Data.Key.RESPONSE_ERROR: error.rawValue + ]) } - var pushClickThroughUrl: URL? { - guard let link = data?[MessagingConstants.Event.Data.Key.PUSH_CLICK_THROUGH_URL] as? String else { + // MARK: - Schema consequence event + + var schemaId: String? { + details?[MessagingConstants.Event.Data.Key.ID] as? String + } + + var schemaType: SchemaType? { + guard let schemaString = details?[MessagingConstants.Event.Data.Key.SCHEMA] as? String else { return nil } - return URL(string: link) + + return SchemaType(from: schemaString) + } + + var schemaData: [String: Any]? { + details?[MessagingConstants.Event.Data.Key.DATA] as? [String: Any] } } diff --git a/AEPMessaging/Sources/Feed.swift b/AEPMessaging/Sources/Feed.swift new file mode 100644 index 00000000..cb84b3b5 --- /dev/null +++ b/AEPMessaging/Sources/Feed.swift @@ -0,0 +1,33 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +@objc(AEPFeed) +@objcMembers +public class Feed: NSObject, Codable { + /// Identification for this feed, represented by the AJO Surface URI used to retrieve it + public let surface: Surface + + /// Friendly name for the feed, provided in the AJO UI + public let name: String + + /// Array of `FeedItemSchemaData` that are members of this `Feed` + public internal(set) var items: [FeedItemSchemaData] + + public init(name: String, surface: Surface, items: [FeedItemSchemaData]) { + self.name = name + self.surface = surface + self.items = items + } +} diff --git a/AEPMessaging/Sources/FeedItem.swift b/AEPMessaging/Sources/FeedItem.swift new file mode 100644 index 00000000..b8afdfec --- /dev/null +++ b/AEPMessaging/Sources/FeedItem.swift @@ -0,0 +1,76 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +@objc(AEPFeedItem) +@objcMembers +public class FeedItem: NSObject, Codable { + /// Plain-text title for the feed item + public let title: String + + /// Plain-text body representing the content for the feed item + public let body: String + + /// String representing a URI that contains an image to be used for this feed item + public let imageUrl: String? + + /// Contains a URL to be opened if the user interacts with the feed item + public let actionUrl: String? + + /// Required if `actionUrl` is provided. Text to be used in title of button or link in feed item + public let actionTitle: String? + + /// Weak reference to parent feedItemSchemaData instance + weak var parent: FeedItemSchemaData? + + enum CodingKeys: String, CodingKey { + case title + case body + case imageUrl + case actionUrl + case actionTitle + } + + public init(title: String, body: String, imageUrl: String? = "", actionUrl: String? = "", actionTitle: String? = "") { + self.title = title + self.body = body + self.imageUrl = imageUrl + self.actionUrl = actionUrl + self.actionTitle = actionTitle + } + + /// Decode FeedItem instance from the given decoder. + /// - Parameter decoder: The decoder to read feed item data from. + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + title = try values.decode(String.self, forKey: .title) + body = try values.decode(String.self, forKey: .body) + imageUrl = try? values.decode(String.self, forKey: .imageUrl) + actionUrl = try? values.decode(String.self, forKey: .actionUrl) + actionTitle = try? values.decode(String.self, forKey: .actionTitle) + } + + /// Encode FeedItem instance into the given encoder. + /// - Parameter encoder: The encoder to write feed item data to. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(title, forKey: .title) + try container.encode(body, forKey: .body) + try? container.encode(imageUrl, forKey: .imageUrl) + try? container.encode(actionUrl, forKey: .actionUrl) + try? container.encode(actionTitle, forKey: .actionTitle) + } +} diff --git a/AEPMessaging/Sources/FeedRulesEngine.swift b/AEPMessaging/Sources/FeedRulesEngine.swift new file mode 100644 index 00000000..4b5f4ffc --- /dev/null +++ b/AEPMessaging/Sources/FeedRulesEngine.swift @@ -0,0 +1,62 @@ +/* + Copyright 2023 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 AEPRulesEngine +import AEPServices +import Foundation + +class FeedRulesEngine { + let launchRulesEngine: LaunchRulesEngine + let runtime: ExtensionRuntime + + /// Initialize this class, creating a new rules engine with the provided name and runtime + init(name: String, extensionRuntime: ExtensionRuntime) { + runtime = extensionRuntime + launchRulesEngine = LaunchRulesEngine(name: name, + extensionRuntime: extensionRuntime) + } + + /// INTERNAL ONLY + /// Initializer to provide a mock rules engine for testing + init(extensionRuntime: ExtensionRuntime, launchRulesEngine: LaunchRulesEngine) { + runtime = extensionRuntime + self.launchRulesEngine = launchRulesEngine + } + + /// if we have rules loaded, then we simply process the event. + func evaluate(event: Event) -> [Surface: [PropositionItem]]? { + let consequences = launchRulesEngine.evaluate(event: event) + guard let consequences = consequences else { + return nil + } + + var propositionItemsBySurface: [Surface: [PropositionItem]] = [:] + for consequence in consequences { + guard let propositionItem = PropositionItem.fromRuleConsequence(consequence), + let propositionAsFeedItem = propositionItem.feedItemSchemaData + else { + continue + } + + let surfaceUri = propositionAsFeedItem.meta?[MessagingConstants.Event.Data.Key.Feed.SURFACE] as? String ?? "" + let surface = Surface(uri: surfaceUri) + + if propositionItemsBySurface[surface] != nil { + propositionItemsBySurface[surface]?.append(propositionItem) + } else { + propositionItemsBySurface[surface] = [propositionItem] + } + } + return propositionItemsBySurface + } +} diff --git a/AEPMessaging/Sources/Message+FullscreenMessageDelegate.swift b/AEPMessaging/Sources/Message+FullscreenMessageDelegate.swift index da83b3ec..ee5c3b73 100644 --- a/AEPMessaging/Sources/Message+FullscreenMessageDelegate.swift +++ b/AEPMessaging/Sources/Message+FullscreenMessageDelegate.swift @@ -21,8 +21,9 @@ extension Message: FullscreenMessageDelegate { } if message.autoTrack { - message.track(nil, withEdgeEventType: .inappDisplay) + message.track(withEdgeEventType: .display) } + message.recordEventHistory(eventType: .display, interaction: nil) } public func onShowFailure() {} @@ -33,6 +34,7 @@ extension Message: FullscreenMessageDelegate { guard let message = message.parent else { return } + message.recordEventHistory(eventType: .dismiss, interaction: nil) message.dismiss() } @@ -65,7 +67,8 @@ extension Message: FullscreenMessageDelegate { // handle optional tracking if let interaction = queryParams[MessagingConstants.IAM.HTML.INTERACTION], !interaction.isEmpty { - message?.track(interaction, withEdgeEventType: .inappInteract) + message?.track(interaction, withEdgeEventType: .interact) + message?.recordEventHistory(eventType: .interact, interaction: interaction) } // dismiss if requested @@ -80,9 +83,9 @@ extension Message: FullscreenMessageDelegate { // handle optional deep link if - let link = queryParams[MessagingConstants.IAM.HTML.LINK], !link.isEmpty, - let deeplinkUrl = URL(string: link.removingPercentEncoding ?? "") - { + let link = queryParams[MessagingConstants.IAM.HTML.LINK], + let decodedLink = link.removingPercentEncoding, !decodedLink.isEmpty, + let deeplinkUrl = URL(string: decodedLink) { UIApplication.shared.open(deeplinkUrl) } diff --git a/AEPMessaging/Sources/Message.swift b/AEPMessaging/Sources/Message.swift index d6376b2c..685f9df5 100644 --- a/AEPMessaging/Sources/Message.swift +++ b/AEPMessaging/Sources/Message.swift @@ -50,25 +50,12 @@ public class Message: NSObject { /// Holds XDM data necessary for tracking `Message` interactions with Adobe Journey Optimizer. var propositionInfo: PropositionInfo? - /// Creates a Message object which owns and controls UI and tracking behavior of an In-App Message. - /// - /// - Parameters: - /// - parent: the `Messaging` object that owns the new `Message` - /// - event: the Rules Consequence `Event` that defines the message and contains reporting information - init(parent: Messaging, event: Event) { + /// Basic initializer only called by convenience constructor + init(parent: Messaging, triggeringEvent: Event) { + id = "" self.parent = parent - triggeringEvent = event - id = event.messageId ?? "" + self.triggeringEvent = triggeringEvent super.init() - let messageSettings = event.getMessageSettings(withParent: self) - let usingLocalAssets = generateAssetMap() - fullscreenMessage = ServiceProvider.shared.uiService.createFullscreenMessage?(payload: event.html ?? "", - listener: self, - isLocalImageUsed: usingLocalAssets, - settings: messageSettings) as? FullscreenMessage - if usingLocalAssets { - fullscreenMessage?.setAssetMap(assets) - } } // MARK: - UI management @@ -89,7 +76,7 @@ public class Message: NSObject { @objc(dismissSuppressingAutoTrack:) public func dismiss(suppressAutoTrack: Bool = false) { if autoTrack, !suppressAutoTrack { - track(nil, withEdgeEventType: .inappDismiss) + track(withEdgeEventType: .dismiss) } fullscreenMessage?.dismiss() @@ -103,8 +90,14 @@ public class Message: NSObject { /// - interaction: a custom `String` value to be recorded in the interaction /// - eventType: the `MessagingEdgeEventType` to be used for the ensuing Edge Event @objc(trackInteraction:withEdgeEventType:) - public func track(_ interaction: String?, withEdgeEventType eventType: MessagingEdgeEventType) { - parent?.sendPropositionInteraction(withEventType: eventType, andInteraction: interaction, forMessage: self) + public func track(_ interaction: String? = nil, withEdgeEventType eventType: MessagingEdgeEventType) { + guard let propInfo = propositionInfo else { + Log.debug(label: MessagingConstants.LOG_TAG, "Unable to send a proposition interaction, proposition info is not found for message (\(id)).") + return + } + + let propositionInteractionXdm = PropositionInteraction(eventType: eventType, interaction: interaction ?? "", propositionInfo: propInfo, itemId: nil, tokens: nil).xdm + parent?.sendPropositionInteraction(withXdm: propositionInteractionXdm) } // MARK: - WebView javascript handling @@ -134,21 +127,68 @@ public class Message: NSObject { /// Called when a `Message` is triggered - i.e. it's conditional criteria have been met. func trigger() { if autoTrack { - track(nil, withEdgeEventType: .inappTrigger) + track(withEdgeEventType: .trigger) + } + recordEventHistory(eventType: .trigger, interaction: nil) + } + + /// Dispatches an event to be recorded in Event History. + /// + /// Record is created using the `propositionInfo.activityId` for this message. + /// + /// - Parameters: + /// - eventType: `MessagingEdgeEventType` to be recorded + /// - interaction: if provided, adds a custom interaction to the hash + func recordEventHistory(eventType: MessagingEdgeEventType, interaction: String?) { + guard let propInfo = propositionInfo else { + Log.debug(label: MessagingConstants.LOG_TAG, "Unable to write event history event '\(eventType.propositionEventType)', proposition info is not available for message (\(id)).") + return + } + + // iam dictionary used for event history + let iamHistory: [String: String] = [ + MessagingConstants.Event.History.Keys.EVENT_TYPE: eventType.propositionEventType, + MessagingConstants.Event.History.Keys.MESSAGE_ID: propInfo.activityId, + MessagingConstants.Event.History.Keys.TRACKING_ACTION: interaction ?? "" + ] + + // wrap history in an "iam" object + let eventHistoryData: [String: Any] = [ + MessagingConstants.Event.Data.Key.IAM_HISTORY: iamHistory + ] + + let mask = [ + MessagingConstants.Event.History.Mask.EVENT_TYPE, + MessagingConstants.Event.History.Mask.MESSAGE_ID, + MessagingConstants.Event.History.Mask.TRACKING_ACTION + ] + + var interactionLog = "" + if let interaction = interaction { + interactionLog = " with value '\(interaction)'" } + Log.trace(label: MessagingConstants.LOG_TAG, "Writing '\(eventType.propositionEventType)' event\(interactionLog) to EventHistory for in-app message with activityId '\(propInfo.activityId)'") + + let event = Event(name: MessagingConstants.Event.Name.EVENT_HISTORY_WRITE, + type: EventType.messaging, + source: MessagingConstants.Event.Source.EVENT_HISTORY_WRITE, + data: eventHistoryData, + mask: mask) + parent?.runtime.dispatch(event: event) } // MARK: - Private methods /// Generates a mapping of the message's assets to their representation in local cache. /// - /// This method will iterate through the `remoteAssets` of the triggering event for the message. + /// This method will iterate through the provided `newAssets`. /// In each iteration, it will check to see if there is a corresponding cache entry for the /// asset string. If a match is found, an entry will be made in the `Message`s `assets` dictionary. /// + /// - Parameter newAssets: optional array of asset urls represented as strings /// - Returns: `true` if an asset map was generated - private func generateAssetMap() -> Bool { - guard let remoteAssetsArray = triggeringEvent.remoteAssets, !remoteAssetsArray.isEmpty else { + private func generateAssetMap(_ newAssets: [String]?) -> Bool { + guard let remoteAssetsArray = newAssets, !remoteAssetsArray.isEmpty else { return false } @@ -164,3 +204,27 @@ public class Message: NSObject { return true } } + +extension Message { + static func fromPropositionItem(_ propositionItem: PropositionItem, with parent: Messaging, triggeringEvent event: Event) -> Message? { + guard let iamSchemaData = propositionItem.inappSchemaData, + let htmlContent = iamSchemaData.content as? String + else { + return nil + } + + let message = Message(parent: parent, triggeringEvent: event) + message.id = propositionItem.itemId + let messageSettings = iamSchemaData.getMessageSettings(with: message) + let usingLocalAssets = message.generateAssetMap(iamSchemaData.remoteAssets) + message.fullscreenMessage = ServiceProvider.shared.uiService.createFullscreenMessage?(payload: htmlContent, + listener: message, + isLocalImageUsed: usingLocalAssets, + settings: messageSettings) as? FullscreenMessage + if usingLocalAssets { + message.fullscreenMessage?.setAssetMap(message.assets) + } + + return message + } +} diff --git a/AEPMessaging/Sources/Messaging+EdgeEvents.swift b/AEPMessaging/Sources/Messaging+EdgeEvents.swift index 6bb9f6ec..62a8116d 100644 --- a/AEPMessaging/Sources/Messaging+EdgeEvents.swift +++ b/AEPMessaging/Sources/Messaging+EdgeEvents.swift @@ -148,14 +148,19 @@ extension Messaging { if var experienceDict = xdmDictResult[MessagingConstants.XDM.AdobeKeys.EXPERIENCE] as? [String: Any] { if var cjmDict = experienceDict[MessagingConstants.XDM.AdobeKeys.CUSTOMER_JOURNEY_MANAGEMENT] as? [String: Any] { // Adding Message profile and push channel context to CUSTOMER_JOURNEY_MANAGEMENT - guard let messageProfile = MessagingConstants.XDM.AdobeKeys.MESSAGE_PROFILE_JSON.toJsonDictionary() else { - Log.warning(label: MessagingConstants.LOG_TAG, - "Failed to update xdmMap with adobe/cjm informations:" + - "converting message profile string to dictionary failed in the event '\(event.id.uuidString)'.") - return xdmDictResult - } + let cjmPushProfile = [ + MessagingConstants.XDM.AdobeKeys.MESSAGE_PROFILE: [ + MessagingConstants.XDM.AdobeKeys.CHANNEL: [ + MessagingConstants.XDM.AdobeKeys._ID: MessagingConstants.XDM.AdobeKeys.PUSH_CHANNEL_ID + ] + ], + MessagingConstants.XDM.AdobeKeys.PUSH_CHANNEL_CONTEXT: [ + MessagingConstants.XDM.AdobeKeys.PLATFORM: MessagingConstants.XDM.AdobeKeys.APNS + ] + ] + // Merging the dictionary - cjmDict.mergeXdm(rhs: messageProfile) + cjmDict.mergeXdm(rhs: cjmPushProfile) experienceDict[MessagingConstants.XDM.AdobeKeys.CUSTOMER_JOURNEY_MANAGEMENT] = cjmDict xdmDictResult[MessagingConstants.XDM.AdobeKeys.EXPERIENCE] = experienceDict } @@ -243,6 +248,18 @@ extension Messaging { return configuration.pushPlatform } + /// Generates and dispatches an event prompting the Edge extension to send a proposition interactions tracking event. + /// + /// - Parameter event: request event containing proposition interaction XDM data + func trackMessages(_ event: Event) { + guard let propositionInteractionXdm = event.propositionInteractionXdm else { + Log.debug(label: MessagingConstants.LOG_TAG, "Cannot track proposition item, proposition interaction XDM is not available.") + return + } + + sendPropositionInteraction(withXdm: propositionInteractionXdm) + } + /// { /// "xdm": { /// "eventType": "decisioning.propositionInteract", @@ -278,77 +295,18 @@ extension Messaging { /// Sends a proposition interaction to the customer's experience event dataset. /// - /// If the message does not contain `scopeDetails`, required for properly tracking in AJO, this method will return as a no-op. - /// /// - Parameters: - /// - eventType: type of event corresponding to this interaction - /// - interaction: a `String` describing the interaction - /// - message: the `Message` for which the interaction should be recorded - func sendPropositionInteraction(withEventType eventType: MessagingEdgeEventType, andInteraction interaction: String?, forMessage message: Message) { - guard let propInfo = message.propositionInfo, !propInfo.scopeDetails.isEmpty else { - Log.debug(label: MessagingConstants.LOG_TAG, "Unable to send a proposition interaction - `scopeDetails` were not found for message (\(message.id)).") - return - } - - let propositions: [[String: Any]] = [ - [ - MessagingConstants.XDM.IAM.Key.ID: propInfo.id, - MessagingConstants.XDM.IAM.Key.SCOPE: propInfo.scope, - MessagingConstants.XDM.IAM.Key.SCOPE_DETAILS: propInfo.scopeDetails.asDictionary() ?? [:] - ] - ] - - let propositionEventType: [String: Int] = [ - eventType.propositionEventType: 1 - ] + /// - xdm: a dictionary containing the proposition interaction XDM. + func sendPropositionInteraction(withXdm xdm: [String: Any]) { + var eventData: [String: Any] = [:] - var decisioning: [String: Any] = [ - MessagingConstants.XDM.IAM.Key.PROPOSITION_EVENT_TYPE: propositionEventType, - MessagingConstants.XDM.IAM.Key.PROPOSITIONS: propositions - ] - - // only add `propositionAction` data if this is an interact event - if eventType == .inappInteract { - let propositionAction: [String: String] = [ - MessagingConstants.XDM.IAM.Key.ID: interaction ?? "", - MessagingConstants.XDM.IAM.Key.LABEL: interaction ?? "" - ] - decisioning[MessagingConstants.XDM.IAM.Key.PROPOSITION_ACTION] = propositionAction - } - - let experience: [String: Any] = [ - MessagingConstants.XDM.IAM.Key.DECISIONING: decisioning - ] - - let xdm: [String: Any] = [ - MessagingConstants.XDM.Key.EVENT_TYPE: eventType.toString(), - MessagingConstants.XDM.AdobeKeys.EXPERIENCE: experience - ] - - // iam dictionary used for event history - let iamHistory: [String: String] = [ - MessagingConstants.Event.History.Keys.EVENT_TYPE: eventType.propositionEventType, - MessagingConstants.Event.History.Keys.MESSAGE_ID: propInfo.activityId, - MessagingConstants.Event.History.Keys.TRACKING_ACTION: interaction ?? "" - ] - - let mask = [ - MessagingConstants.Event.History.Mask.EVENT_TYPE, - MessagingConstants.Event.History.Mask.MESSAGE_ID, - MessagingConstants.Event.History.Mask.TRACKING_ACTION - ] - - let xdmEventData: [String: Any] = [ - MessagingConstants.XDM.Key.XDM: xdm, - MessagingConstants.Event.Data.Key.IAM_HISTORY: iamHistory - ] + eventData[MessagingConstants.XDM.Key.XDM] = xdm // Creating xdm edge event with request content source type let event = Event(name: MessagingConstants.Event.Name.MESSAGE_INTERACTION, type: EventType.edge, source: EventSource.requestContent, - data: xdmEventData, - mask: mask) + data: eventData) dispatch(event: event) } diff --git a/AEPMessaging/Sources/Messaging+PublicAPI.swift b/AEPMessaging/Sources/Messaging+PublicAPI.swift index bb4eb348..1d3dcf55 100644 --- a/AEPMessaging/Sources/Messaging+PublicAPI.swift +++ b/AEPMessaging/Sources/Messaging+PublicAPI.swift @@ -15,47 +15,21 @@ import AEPServices import UserNotifications @objc public extension Messaging { - /// Sends the push notification interactions as an experience event to Adobe Experience Edge. - /// - Parameters: - /// - response: UNNotificationResponse object which contains the payload and xdm informations. - /// - applicationOpened: Boolean values denoting whether the application was opened when notification was clicked - /// - customActionId: String value of the custom action (e.g button id on the notification) which was clicked. - @available(*, deprecated, message: "This method is deprecated. Use Messaging.handleNotificationResponse(:) instead to automatically track application open and handle notification actions.") - @objc(handleNotificationResponse:applicationOpened:withCustomActionId:) - static func handleNotificationResponse(_ response: UNNotificationResponse, applicationOpened: Bool, customActionId: String?) { - let notificationRequest = response.notification.request - - // Checking if the message has the _xdm key that contains tracking information - guard let xdm = notificationRequest.content.userInfo[MessagingConstants.XDM.AdobeKeys._XDM] as? [String: Any], !xdm.isEmpty else { - Log.debug(label: MessagingConstants.LOG_TAG, "XDM specific fields are missing from push notification response. Ignoring to track push notification.") - return - } - - // Creating event data with tracking informations - var eventData: [String: Any] = [MessagingConstants.Event.Data.Key.MESSAGE_ID: notificationRequest.identifier, - MessagingConstants.Event.Data.Key.APPLICATION_OPENED: applicationOpened, - MessagingConstants.XDM.Key.ADOBE_XDM: xdm] - if customActionId == nil { - eventData[MessagingConstants.Event.Data.Key.EVENT_TYPE] = MessagingConstants.XDM.Push.EventType.APPLICATION_OPENED - } else { - eventData[MessagingConstants.Event.Data.Key.EVENT_TYPE] = MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION - eventData[MessagingConstants.Event.Data.Key.ACTION_ID] = customActionId - } - - let event = Event(name: MessagingConstants.Event.Name.PUSH_NOTIFICATION_INTERACTION, - type: MessagingConstants.Event.EventType.messaging, - source: EventSource.requestContent, - data: eventData) - MobileCore.dispatch(event: event) - } - /// Sends the push notification interactions as an experience event to Adobe Experience Edge. /// This API method will also automatically handle click behavior defined for the push notification. + /// Use the optional urlHandler callback to handle the actionable URL from the push notification. + /// The urlHandler does not get called for push notification that is not originated from Adobe Journey Optimizer. + /// If the urlHandler closure returns `true`, the SDK will not handle the URL and the application is responsible for handling the URL. + /// If the urlHandler closure returns `false`, the SDK will handle the opening of the URL. + /// /// - Parameters: /// - response: UNNotificationResponse object which contains the payload and xdm informations. + /// - urlHandler: An optional closure to handle the actionable URL from the push notification. /// - closure : An optional callback with `PushTrackingStatus` representing the tracking status of the interacted notification - @objc(handleNotificationResponse:closure:) - static func handleNotificationResponse(_ response: UNNotificationResponse, closure: ((PushTrackingStatus) -> Void)? = nil) { + @objc(handleNotificationResponse:urlHandler:closure:) + static func handleNotificationResponse(_ response: UNNotificationResponse, + urlHandler: ((URL) -> Bool)? = nil, + closure: ((PushTrackingStatus) -> Void)? = nil) { let notificationRequest = response.notification.request // Checking if the message has the _xdm key that contains tracking information @@ -69,14 +43,14 @@ import UserNotifications DispatchQueue.global().async { hasApplicationOpenedForResponse(response, completion: { isAppOpened in - let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.MESSAGE_ID: notificationRequest.identifier, + let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.ID: notificationRequest.identifier, MessagingConstants.Event.Data.Key.APPLICATION_OPENED: isAppOpened, MessagingConstants.Event.Data.Key.ADOBE_XDM: xdm] - let modifiedEventData = addNotificationActionToEventData(eventData, response) + let modifiedEventData = addNotificationActionToEventData(eventData, response, urlHandler) let event = Event(name: MessagingConstants.Event.Name.PUSH_NOTIFICATION_INTERACTION, - type: MessagingConstants.Event.EventType.messaging, + type: EventType.messaging, source: EventSource.requestContent, data: modifiedEventData) @@ -95,13 +69,86 @@ import UserNotifications static func refreshInAppMessages() { let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.REFRESH_MESSAGES: true] let event = Event(name: MessagingConstants.Event.Name.REFRESH_MESSAGES, - type: MessagingConstants.Event.EventType.messaging, + type: EventType.messaging, source: EventSource.requestContent, data: eventData) MobileCore.dispatch(event: event) } + // MARK: Personalization via Surfaces + + /// Dispatches an event to fetch propositions for the provided surfaces from remote. + /// - Parameter surfaces: An array of surface objects. + static func updatePropositionsForSurfaces(_ surfaces: [Surface]) { + let validSurfaces = surfaces + .filter { $0.isValid } + + guard !validSurfaces.isEmpty else { + Log.warning(label: MessagingConstants.LOG_TAG, + "Cannot update propositions as the provided surfaces array has no valid items.") + return + } + + let eventData: [String: Any] = [ + MessagingConstants.Event.Data.Key.UPDATE_PROPOSITIONS: true, + MessagingConstants.Event.Data.Key.SURFACES: validSurfaces.compactMap { $0.asDictionary() } + ] + + let event = Event(name: MessagingConstants.Event.Name.UPDATE_PROPOSITIONS, + type: EventType.messaging, + source: EventSource.requestContent, + data: eventData) + + MobileCore.dispatch(event: event) + } + + /// Retrieves the previously fetched (and cached) feeds content from the SDK for the provided surfaces. + /// If the feeds content for one or more surfaces isn't previously cached in the SDK, it will not be retrieved from Adobe Journey Optimizer via the Experience Edge network. + /// - Parameters: + /// - surfacePaths: An array of surface objects. + /// - completion: The completion handler to be invoked with a dictionary containing the surface objects and the corresponding array of Proposition objects. + static func getPropositionsForSurfaces(_ surfacePaths: [Surface], _ completion: @escaping ([Surface: [Proposition]]?, Error?) -> Void) { + let validSurfaces = surfacePaths + .filter { $0.isValid } + + guard !validSurfaces.isEmpty else { + Log.warning(label: MessagingConstants.LOG_TAG, + "Cannot get propositions as the provided surfaces array has no valid items.") + completion(nil, AEPError.invalidRequest) + return + } + + let eventData: [String: Any] = [ + MessagingConstants.Event.Data.Key.GET_PROPOSITIONS: true, + MessagingConstants.Event.Data.Key.SURFACES: validSurfaces.compactMap { $0.asDictionary() } + ] + + let event = Event(name: MessagingConstants.Event.Name.GET_PROPOSITIONS, + type: EventType.messaging, + source: EventSource.requestContent, + data: eventData) + + MobileCore.dispatch(event: event, timeout: 1) { responseEvent in + guard let responseEvent = responseEvent else { + completion(nil, AEPError.callbackTimeout) + return + } + + if let error = responseEvent.responseError { + completion(nil, error) + return + } + + guard let propositions = responseEvent.propositions else { + completion(nil, AEPError.unexpected) + return + } + + completion(propositions.toDictionary { Surface(uri: $0.scope) }, .none) + } + } + // MARK: - Private Helper Methods /// Determines whether the user's response to a notification has caused the application to open @@ -114,7 +161,8 @@ import UserNotifications /// - completion: The completion block to be executed with a boolean value determining if application was opened because of user's interaction with the notification. /// /// - Note: The completion handler is invoked asynchronously, so any code relying on the result should be placed within the completion handler or called from there. - private static func hasApplicationOpenedForResponse(_ response: UNNotificationResponse, completion: @escaping (Bool) -> Void) { + private static func hasApplicationOpenedForResponse(_ response: UNNotificationResponse, + completion: @escaping (Bool) -> Void) { switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: completion(true) @@ -146,8 +194,11 @@ import UserNotifications /// - Parameters: /// - eventData: The original event data dictionary. /// - response: The user's response to a notification, represented by a `UNNotificationResponse` object. + /// - urlHandler: An optional closure defined in the consumer app to handle the actionable URL from the push notification. /// - Returns: The modified event data dictionary. - private static func addNotificationActionToEventData(_ eventData: [String: Any], _ response: UNNotificationResponse) -> [String: Any] { + private static func addNotificationActionToEventData(_ eventData: [String: Any], + _ response: UNNotificationResponse, + _ urlHandler: ((URL) -> Bool)?) -> [String: Any] { var modifiedEventData = eventData switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: @@ -155,10 +206,26 @@ import UserNotifications // This results in opening of the application. modifiedEventData[MessagingConstants.Event.Data.Key.EVENT_TYPE] = MessagingConstants.XDM.Push.EventType.APPLICATION_OPENED - // Add actionable URL to eventData if available - if let clickThroughURL = response.notification.request.content.userInfo[MessagingConstants.PushNotification.UserInfoKey.ACTION_URL] { - modifiedEventData[MessagingConstants.Event.Data.Key.PUSH_CLICK_THROUGH_URL] = clickThroughURL + let userInfo = response.notification.request.content.userInfo + // If the notification does not contain a valid click through URL, log a warning and break + guard let clickThroughURLString = userInfo[MessagingConstants.PushNotification.UserInfoKey.ACTION_URL] as? String, + let clickThroughURL = URL(string: clickThroughURLString) + else { + Log.warning(label: MessagingConstants.LOG_TAG, "Invalid or missing click through URL on notification.") + break + } + + // If the urlHandler is not defined by the consumer app, then add the click through URL to the event data. + guard let urlHandler = urlHandler else { + modifiedEventData[MessagingConstants.Event.Data.Key.PUSH_CLICK_THROUGH_URL] = clickThroughURLString + break } + + // If the urlHandler returns false, then add the click through URL to the event data. + if !urlHandler(clickThroughURL) { + modifiedEventData[MessagingConstants.Event.Data.Key.PUSH_CLICK_THROUGH_URL] = clickThroughURLString + } + case UNNotificationDismissActionIdentifier: // actionIdentifier `UNNotificationDismissActionIdentifier` indicates user has dismissed the // notification by tapping "Clear" action button. diff --git a/AEPMessaging/Sources/Messaging+State.swift b/AEPMessaging/Sources/Messaging+State.swift new file mode 100644 index 00000000..09748c4d --- /dev/null +++ b/AEPMessaging/Sources/Messaging+State.swift @@ -0,0 +1,65 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +extension Messaging { + /// Loads propositions from persistence into memory then hydrates the messaging rules engine + func loadCachedPropositions() { + guard let cachedPropositions = cache.propositions else { + return + } + propositions = cachedPropositions + hydratePropositionsRulesEngine() + } + + func updatePropositionInfo(_ newPropositionInfo: [String: PropositionInfo], removing surfaces: [Surface]? = nil) { + propositionInfo.merge(newPropositionInfo) { _, new in new } + + // currently, we can't remove entries that pre-exist by message id since they are not linked to surfaces + // need to get surface uri from propositionInfo.scope and remove entry based on incoming `surfaces` + if let surfaces = surfaces { + propositionInfo = propositionInfo.filter { propInfo in + !surfaces.contains { $0.uri == propInfo.value.scope } + } + } + } + + func updatePropositions(_ newPropositions: [Surface: [Proposition]], removing surfaces: [Surface]? = nil) { + // add new surfaces or update replace existing surfaces + for (surface, propositionsArray) in newPropositions { + propositions.addArray(propositionsArray, forKey: surface) + } + + // remove any surfaces if necessary + if let surfaces = surfaces { + for surface in surfaces { + propositions.removeValue(forKey: surface) + } + } + } + + // MARK: - private methods + + private func hydratePropositionsRulesEngine() { + let parsedPropositions = ParsedPropositions(with: propositions, requestedSurfaces: propositions.map { $0.key }, runtime: runtime) + if let inAppRules = parsedPropositions.surfaceRulesBySchemaType[.inapp] { + rulesEngine.launchRulesEngine.replaceRules(with: inAppRules.flatMap { $0.value }) + } + } + + private func removeCachedPropositions(surfaces: [Surface]) { + cache.updatePropositions(nil, removing: surfaces) + } +} diff --git a/AEPMessaging/Sources/Messaging.swift b/AEPMessaging/Sources/Messaging.swift index dd9907f6..55269581 100644 --- a/AEPMessaging/Sources/Messaging.swift +++ b/AEPMessaging/Sources/Messaging.swift @@ -24,29 +24,93 @@ public class Messaging: NSObject, Extension { public var metadata: [String: String]? public var runtime: ExtensionRuntime - private var messagesRequestEventId: String? - private var lastProcessedRequestEventId: String? + // Operation orderer used to maintain the order of update and get propositions events. + // It ensures any update propositions requests issued before a get propositions call are completed + // and the get propositions request is fulfilled from the latest cached content. + private let eventsQueue = OperationOrderer("MessagingEvents") + + // MARK: - Messaging State + + var cache: Cache = .init(name: MessagingConstants.Caches.CACHE_NAME) + var appSurface: String { + Bundle.main.mobileappSurface + } + private var initialLoadComplete = false - private let rulesEngine: MessagingRulesEngine + let rulesEngine: MessagingRulesEngine + let feedRulesEngine: FeedRulesEngine + + /// Dispatch queue used to protect against simultaneous access of our containers from multiple threads + private let queue: DispatchQueue = .init(label: "com.adobe.messaging.containers.queue") + + /// stores CBE propositions (json-content, html-content, default-content) + private var _propositions: [Surface: [Proposition]] = [:] + var propositions: [Surface: [Proposition]] { + get { queue.sync { self._propositions } } + set { queue.async { self._propositions = newValue } } + } + + /// propositionInfo stored by RuleConsequence.id + private var _propositionInfo: [String: PropositionInfo] = [:] + var propositionInfo: [String: PropositionInfo] { + get { queue.sync { self._propositionInfo } } + set { queue.async { self._propositionInfo = newValue } } + } + + /// keeps a list of all surfaces requested per personalization request event by event id + private var _requestedSurfacesForEventId: [String: [Surface]] = [:] + private var requestedSurfacesForEventId: [String: [Surface]] { + get { queue.sync { self._requestedSurfacesForEventId } } + set { queue.async { self._requestedSurfacesForEventId = newValue } } + } + + /// used while processing streaming payloads for a single request + private var _inProgressPropositions: [Surface: [Proposition]] = [:] + private var inProgressPropositions: [Surface: [Proposition]] { + get { queue.sync { self._inProgressPropositions } } + set { queue.async { self._inProgressPropositions = newValue } } + } + + private var _inAppRulesBySurface: [Surface: [LaunchRule]] = [:] + private var inAppRulesBySurface: [Surface: [LaunchRule]] { + get { queue.sync { self._inAppRulesBySurface } } + set { queue.async { self._inAppRulesBySurface = newValue } } + } + + /// used to manage feed rules between multiple surfaces and multiple requests + private var _feedRulesBySurface: [Surface: [LaunchRule]] = [:] + private var feedRulesBySurface: [Surface: [LaunchRule]] { + get { queue.sync { self._feedRulesBySurface } } + set { queue.async { self._feedRulesBySurface = newValue } } + } + + /// Array containing the schema strings for the proposition items supported by the SDK, sent in the personalization query request. + static let supportedSchemas = [ + MessagingConstants.PersonalizationSchemas.HTML_CONTENT, + MessagingConstants.PersonalizationSchemas.JSON_CONTENT, + MessagingConstants.PersonalizationSchemas.RULESET_ITEM + ] // MARK: - Extension protocol methods public required init?(runtime: ExtensionRuntime) { self.runtime = runtime - rulesEngine = MessagingRulesEngine(name: MessagingConstants.RULES_ENGINE_NAME, - extensionRuntime: runtime) + MessagingMigrator.migrate(cache: cache) + rulesEngine = MessagingRulesEngine(name: MessagingConstants.RULES_ENGINE_NAME, extensionRuntime: runtime, cache: cache) + feedRulesEngine = FeedRulesEngine(name: MessagingConstants.FEED_RULES_ENGINE_NAME, extensionRuntime: runtime) super.init() - rulesEngine.loadCachedPropositions(for: appSurface) + loadCachedPropositions() } /// INTERNAL ONLY /// used for testing - init(runtime: ExtensionRuntime, rulesEngine: MessagingRulesEngine, expectedScope: String) { + init(runtime: ExtensionRuntime, rulesEngine: MessagingRulesEngine, feedRulesEngine: FeedRulesEngine, expectedSurfaceUri _: String, cache: Cache) { self.runtime = runtime self.rulesEngine = rulesEngine - self.rulesEngine.loadCachedPropositions(for: expectedScope) - + self.feedRulesEngine = feedRulesEngine + self.cache = cache super.init() + loadCachedPropositions() } public func onRegistered() { @@ -56,7 +120,7 @@ public class Messaging: NSObject, Extension { listener: handleProcessEvent) // register listener for Messaging request content event - registerListener(type: MessagingConstants.Event.EventType.messaging, + registerListener(type: EventType.messaging, source: EventSource.requestContent, listener: handleProcessEvent) @@ -74,6 +138,23 @@ public class Messaging: NSObject, Extension { registerListener(type: EventType.edge, source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, listener: handleEdgePersonalizationNotification) + + // register listener for handling personalization request complete events + registerListener(type: EventType.messaging, + source: EventSource.contentComplete, + listener: handleProcessCompletedEvent(_:)) + + // Handler function called for each queued event. If the queued event is a get propositions event, process it + // otherwise if it is an Edge event to update propositions, process it only if it is completed. + eventsQueue.setHandler { event -> Bool in + if event.isGetPropositionsEvent { + self.retrieveMessages(for: event.surfaces ?? [], event: event) + } else if event.type == EventType.edge { + return !self.requestedSurfacesForEventId.keys.contains(event.id.uuidString) + } + return true + } + eventsQueue.start() } public func onUnregistered() { @@ -99,7 +180,7 @@ public class Messaging: NSObject, Extension { // once we have valid configuration, fetch message definitions from offers if we haven't already if !initialLoadComplete { initialLoadComplete = true - fetchMessages() + fetchMessages(event) } return true @@ -112,101 +193,359 @@ public class Messaging: NSObject, Extension { rulesEngine.process(event: event) } - /// Generates and dispatches an event prompting the Edge extension to fetch in-app messages. - /// The app surface used in the request is generated using the `bundleIdentifier` for the app. + /// Generates and dispatches an event prompting the Edge extension to fetch in-app or feed messages or code-based experiences. + /// + /// The surface URIs used in the request are generated using the `bundleIdentifier` for the app. /// If the `bundleIdentifier` is unavailable, calling this method will do nothing. - private func fetchMessages() { - guard appSurface != "unknown" else { - Log.warning(label: MessagingConstants.LOG_TAG, "Unable to retrieve in-app messages - unable to retrieve bundle identifier.") - return + /// + /// - Parameters: + /// - event - do this + /// - surfaces: an array of surface path strings for fetching feed messages, if available. + private func fetchMessages(_ event: Event, for surfaces: [Surface]? = nil) { + var requestedSurfaces: [Surface] = [] + + // if surfaces are provided, use them - otherwise assume the request is for base surface (mobileapp://{bundle identifier}) + if let surfaces = surfaces { + requestedSurfaces = surfaces.filter { $0.isValid } + + guard !requestedSurfaces.isEmpty else { + Log.debug(label: MessagingConstants.LOG_TAG, "Unable to update messages, no valid surfaces found.") + return + } + } else { + guard appSurface != "unknown" else { + Log.warning(label: MessagingConstants.LOG_TAG, "Unable to update messages, cannot read the bundle identifier.") + return + } + requestedSurfaces = [Surface(uri: appSurface)] } + // begin construction of event data var eventData: [String: Any] = [:] - let messageRequestData: [String: Any] = [ - MessagingConstants.XDM.IAM.Key.PERSONALIZATION: [ - MessagingConstants.XDM.IAM.Key.SURFACES: [appSurface] + // add `query` parameters containing supported schemas and requested surfaces + eventData[MessagingConstants.XDM.Inbound.Key.QUERY] = [ + MessagingConstants.XDM.Inbound.Key.PERSONALIZATION: [ + MessagingConstants.XDM.Inbound.Key.SCHEMAS: Messaging.supportedSchemas, + MessagingConstants.XDM.Inbound.Key.SURFACES: requestedSurfaces.compactMap { $0.uri } ] ] - eventData[MessagingConstants.XDM.IAM.Key.QUERY] = messageRequestData - let xdmData: [String: Any] = [ - MessagingConstants.XDM.Key.EVENT_TYPE: MessagingConstants.XDM.IAM.EventType.PERSONALIZATION_REQUEST + // add `xdm` with an event type of `personalization.request` + eventData[MessagingConstants.XDM.Key.XDM] = [ + MessagingConstants.XDM.Key.EVENT_TYPE: MessagingConstants.XDM.Inbound.EventType.PERSONALIZATION_REQUEST ] - eventData[MessagingConstants.XDM.Key.XDM] = xdmData - let event = Event(name: MessagingConstants.Event.Name.RETRIEVE_MESSAGE_DEFINITIONS, - type: EventType.edge, - source: EventSource.requestContent, - data: eventData) + // add a `data` object to the request specifying the format desired in the response from XAS + eventData[MessagingConstants.Event.Data.Key.DATA] = [ + MessagingConstants.Event.Data.AdobeKeys.NAMESPACE: [ + MessagingConstants.Event.Data.AdobeKeys.AJO: [ + MessagingConstants.Event.Data.AdobeKeys.INAPP_RESPONSE_FORMAT: MessagingConstants.XDM.Inbound.Value.IAM_RESPONSE_FORMAT + ] + ] + ] - // equal to `requestEventId` in aep response handles - // used for ensuring that the messaging extension is responding to the correct handle - messagesRequestEventId = event.id.uuidString + // add a `request` object so we get a response event from edge when the propositions stream is closed for this event + eventData[MessagingConstants.XDM.Key.REQUEST] = [ + MessagingConstants.XDM.Key.SEND_COMPLETION: true + ] + // end construction of event data + + let newEvent = event.createChainedEvent(name: MessagingConstants.Event.Name.RETRIEVE_MESSAGE_DEFINITIONS, + type: EventType.edge, + source: EventSource.requestContent, + data: eventData) + + // create entries in our local containers for managing streamed responses from edge + beginRequestFor(newEvent, with: requestedSurfaces) + + // dispatch the event and implement handler for the completion event + MobileCore.dispatch(event: newEvent, timeout: 10.0) { responseEvent in + // responseEvent is the event dispatched by Edge extension when a request's stream has been closed + guard let responseEvent = responseEvent, + let endingEventId = responseEvent.requestEventId + else { + // response event failed or timed out, need to remove this event from the queue + self.requestedSurfacesForEventId.removeValue(forKey: newEvent.id.uuidString) + self.eventsQueue.start() + + Log.warning(label: MessagingConstants.LOG_TAG, "Unable to run completion logic for a personalization request event - unable to obtain parent event ID") + return + } + + // dispatch an event signaling messaging extension needs to finalize this event + // it must be dispatched to the event queue to avoid a race with the events containing propositions + let processCompletedEvent = responseEvent.createChainedEvent(name: MessagingConstants.Event.Name.FINALIZE_PROPOSITIONS_RESPONSE, + type: EventType.messaging, + source: EventSource.contentComplete, + data: [MessagingConstants.Event.Data.Key.ENDING_EVENT_ID: endingEventId]) + self.dispatch(event: processCompletedEvent) + } + } + + func handleProcessCompletedEvent(_ event: Event) { + defer { + // kick off processing the internal events queue after processing is completed for an update propositions request + eventsQueue.start() + } + + guard let endingEventId = event.data?[MessagingConstants.Event.Data.Key.ENDING_EVENT_ID] as? String, + let requestedSurfaces = requestedSurfacesForEventId[endingEventId] + else { + // shouldn't ever get here, but if we do, we don't have anything to process so we should bail + return + } - // send event - runtime.dispatch(event: event) + Log.trace(label: MessagingConstants.LOG_TAG, "End of streaming response events for requesting event '\(endingEventId)'") + endRequestFor(eventId: endingEventId) + + // dispatch notification event for request + dispatchNotificationEventFor(event, requestedSurfaces: requestedSurfaces) } - private var appSurface: String { - guard let bundleIdentifier = Bundle.main.bundleIdentifier, !bundleIdentifier.isEmpty else { - return "unknown" + private func getPropositionsFromFeedRulesEngine(_ event: Event) -> [Surface: [Proposition]] { + var surfacePropositions: [Surface: [Proposition]] = [:] + + if let propositionItemsBySurface = feedRulesEngine.evaluate(event: event) { + for (surface, propositionItemsArray) in propositionItemsBySurface { + var tempPropositions: [Proposition] = [] + for propositionItem in propositionItemsArray { + guard let propositionInfo = propositionInfo[propositionItem.itemId] else { + continue + } + + // get proposition that this item belongs to + let proposition = Proposition( + uniqueId: propositionInfo.id, + scope: propositionInfo.scope, + scopeDetails: propositionInfo.scopeDetails, + items: [propositionItem] + ) + + // check to see if that proposition is already in the array (based on ID) + // if yes, append the propositionItem. if not, create a new entry for the + // proposition with the new item. + + if let existingProposition = tempPropositions.first(where: { $0.uniqueId == proposition.uniqueId }) { + propositionItem.proposition = existingProposition + existingProposition.items.append(propositionItem) + } else { + propositionItem.proposition = proposition + tempPropositions.append(proposition) + } + } + + surfacePropositions.addArray(tempPropositions, forKey: surface) + } } - return MessagingConstants.XDM.IAM.SURFACE_BASE + bundleIdentifier + return surfacePropositions + } + + private func dispatchNotificationEventFor(_ event: Event, requestedSurfaces: [Surface]) { + let requestedPropositions = retrieveCachedPropositions(for: requestedSurfaces) + guard !requestedPropositions.isEmpty else { + Log.trace(label: MessagingConstants.LOG_TAG, "Not dispatching a notification event, personalization:decisions response does not contain propositions.") + return + } + + // dispatch an event with the propositions received from the remote + let eventData = [MessagingConstants.Event.Data.Key.PROPOSITIONS: requestedPropositions.flatMap { $0.value }].asDictionary() + + let notificationEvent = event.createChainedEvent(name: MessagingConstants.Event.Name.MESSAGE_PROPOSITIONS_NOTIFICATION, + type: EventType.messaging, + source: EventSource.notification, + data: eventData) + + dispatch(event: notificationEvent) + } + + private func beginRequestFor(_ event: Event, with surfaces: [Surface]) { + requestedSurfacesForEventId[event.id.uuidString] = surfaces + + // add the Edge request event to update propositions in the events queue. + eventsQueue.add(event) + } + + private func endRequestFor(eventId: String) { + // update in memory propositions + applyPropositionChangeFor(eventId: eventId) + + // remove event from surfaces dictionary + requestedSurfacesForEventId.removeValue(forKey: eventId) + + // clear pending propositions + inProgressPropositions.removeAll() + } + + private func applyPropositionChangeFor(eventId: String) { + // get the list of requested surfaces for this event + guard let requestedSurfaces = requestedSurfacesForEventId[eventId] else { + return + } + + let parsedPropositions = ParsedPropositions(with: inProgressPropositions, requestedSurfaces: requestedSurfaces, runtime: runtime) + + // we need to preserve cache for any surfaces that were not a part of this request + // any requested surface that is absent from the response needs to be removed from cache and persistence + let returnedSurfaces = Array(inProgressPropositions.keys) as [Surface] + let surfacesToRemove = requestedSurfaces.minus(returnedSurfaces) + + // update persistence, reporting data cache, and finally rules engine for in-app messages + // order matters here because the rules engine must be a full replace, and when we update + // persistence we will be removing empty surfaces and making sure unrequested surfaces + // continue to have their rules active + updatePropositions(parsedPropositions.propositionsToCache, removing: surfacesToRemove) + updatePropositionInfo(parsedPropositions.propositionInfoToCache, removing: surfacesToRemove) + cache.updatePropositions(parsedPropositions.propositionsToPersist, removing: surfacesToRemove) + + // apply rules + updateRulesEngines(with: parsedPropositions.surfaceRulesBySchemaType, requestedSurfaces: requestedSurfaces) + } + + private func updateRulesEngines(with rules: [SchemaType: [Surface: [LaunchRule]]], requestedSurfaces: [Surface]) { + for (inboundType, newRules) in rules { + let surfacesToRemove = requestedSurfaces.minus(Array(newRules.keys)) + switch inboundType { + case .inapp: + Log.trace(label: MessagingConstants.LOG_TAG, "Updating in-app message definitions for surfaces \(newRules.compactMap { $0.key.uri }).") + + // replace rules for each in-app surface we got back + inAppRulesBySurface.merge(newRules) { _, new in new } + + // remove any surfaces that were requested but had no in-app content returned + for surface in surfacesToRemove { + // calls for a dictionary extension? + inAppRulesBySurface.removeValue(forKey: surface) + } + + // combine all our rules + let allInAppRules = inAppRulesBySurface.flatMap { $0.value } + + // pre-fetch the assets for this message if there are any defined + rulesEngine.cacheRemoteAssetsFor(allInAppRules) + + // update rules in in-app engine + rulesEngine.launchRulesEngine.replaceRules(with: allInAppRules) + + case .feed: + Log.trace(label: MessagingConstants.LOG_TAG, "Updating feed definitions for surfaces \(newRules.compactMap { $0.key.uri }).") + + // replace rules for each feed surface we got back + feedRulesBySurface.merge(newRules) { _, new in new } + + // remove any surfaces that were requested but had no in-app content returned + for surface in surfacesToRemove { + feedRulesBySurface.removeValue(forKey: surface) + } + + // update rules in feed rules engine + feedRulesEngine.launchRulesEngine.replaceRules(with: feedRulesBySurface.flatMap { $0.value }) + + default: + // no-op + Log.trace(label: MessagingConstants.LOG_TAG, "No action will be taken updating messaging rules - the InboundType provided is not supported.") + } + } + } + + /// Dispatch an event containing all propositions for the given surface + private func retrieveMessages(for surfaces: [Surface], event: Event) { + let requestedSurfaces = surfaces.filter { $0.isValid } + + guard !requestedSurfaces.isEmpty else { + Log.debug(label: MessagingConstants.LOG_TAG, "Unable to retrieve feed messages, no valid surface paths found.") + dispatch(event: event.createErrorResponseEvent(AEPError.invalidRequest)) + return + } + + // get propositions from rules engine where conditions are met by this event + let ruleConsequencePropositions = getPropositionsFromFeedRulesEngine(event) + // get cached propositions + var requestedPropositions = retrieveCachedPropositions(for: requestedSurfaces) + + for (surface, propositions) in ruleConsequencePropositions { + requestedPropositions.addArray(propositions, forKey: surface) + } + + let eventData = [MessagingConstants.Event.Data.Key.PROPOSITIONS: requestedPropositions.flatMap { $0.value }].asDictionary() + + let responseEvent = event.createResponseEvent( + name: MessagingConstants.Event.Name.MESSAGE_PROPOSITIONS_RESPONSE, + type: EventType.messaging, + source: EventSource.responseContent, + data: eventData + ) + dispatch(event: responseEvent) } /// Validates that the received event contains in-app message definitions and loads them in the `MessagingRulesEngine`. /// - Parameter event: an `Event` containing an in-app message definition in its data private func handleEdgePersonalizationNotification(_ event: Event) { - // validate the event - guard event.isPersonalizationDecisionResponse, event.requestEventId == messagesRequestEventId else { - // either this isn't the type of response we are waiting for, or it's not a response for our request + // validate this is one of our events + guard event.isPersonalizationDecisionResponse, + let requestEventId = event.requestEventId, + requestedSurfacesForEventId.contains(where: { $0.key == requestEventId }) + else { + // either this isn't the type of response we are waiting for, or it's not a response to one of our requests return } - // if this is an event for a new request, purge cache and update lastProcessedRequestEventId - var clearExistingRules = false - if lastProcessedRequestEventId != event.requestEventId { - clearExistingRules = true - lastProcessedRequestEventId = event.requestEventId + Log.trace(label: MessagingConstants.LOG_TAG, "Processing propositions from personalization:decisions network response for event '\(requestEventId)'.") + updateInProgressPropositionsWith(event) + } + + private func updateInProgressPropositionsWith(_ event: Event) { + guard event.requestEventId != nil else { + Log.trace(label: MessagingConstants.LOG_TAG, "Ignoring personalization:decisions response with no requesting Event ID.") + return + } + guard let eventPropositions = event.payload else { + Log.trace(label: MessagingConstants.LOG_TAG, "Ignoring personalization:decisions response with no propositions.") + return } - Log.trace(label: MessagingConstants.LOG_TAG, "Loading in-app message definitions from personalization:decisions network response.") - rulesEngine.loadPropositions(event.payload, clearExisting: clearExistingRules, expectedScope: appSurface) + // loop through propositions for this event and add them to existing props by surface + for proposition in eventPropositions { + let surface = Surface(uri: proposition.scope) + inProgressPropositions.add(proposition, forKey: surface) + } } - /// Handles Rules Consequence events containing message definitions. - private func handleRulesResponse(_ event: Event) { - if event.data == nil { - Log.warning(label: MessagingConstants.LOG_TAG, "Unable to process a Rules Consequence Event. Event data is null.") - return + /// Returns propositions by surface from `propositions` matching the provided `surfaces` + private func retrieveCachedPropositions(for surfaces: [Surface]) -> [Surface: [Proposition]] { + propositions.filter { surface, _ in + surfaces.contains(where: { $0.uri == surface.uri }) } + } - if event.isInAppMessage, event.containsValidInAppMessage { - showMessageForEvent(event) - } else { - Log.warning(label: MessagingConstants.LOG_TAG, "Unable to process In-App Message - html property is required.") + /// Handles Rules Consequence events containing schemas + private func handleRulesResponse(_ event: Event) { + guard event.isSchemaConsequence, event.data != nil else { + Log.trace(label: MessagingConstants.LOG_TAG, "Ignoring rule consequence event. Either consequence is not of type 'schema' or 'eventData' is nil.") return } + + handleSchemaConsequence(event) } - /// Creates and shows a fullscreen message as defined by the contents of the provided `Event`'s data. - /// - Parameter event: the `Event` containing data necessary to create the message and report on it - private func showMessageForEvent(_ event: Event) { - guard event.html != nil else { - Log.debug(label: MessagingConstants.LOG_TAG, "Unable to show message for event \(event.id) - it contains no HTML defining the message.") + private func handleSchemaConsequence(_ event: Event) { + guard let propositionItem = PropositionItem.fromRuleConsequenceEvent(event) else { return } - let message = Message(parent: self, event: event) - message.propositionInfo = rulesEngine.propositionInfoForMessageId(message.id) - if message.propositionInfo == nil { - Log.warning(label: MessagingConstants.LOG_TAG, "Preparing to show a message that does not contain information necessary for tracking with Adobe Journey Optimizer. If you are spoofing this message from the AJO authoring UI or from Assurance, ignore this message.") + switch propositionItem.schema { + case .inapp: + if + let message = Message.fromPropositionItem(propositionItem, with: self, triggeringEvent: event), + let propositionInfo = propositionInfoFor(messageId: propositionItem.itemId) { + message.propositionInfo = propositionInfo + message.trigger() + message.show(withMessagingDelegateControl: true) + } + default: + return } - - message.trigger() - message.show(withMessagingDelegateControl: true) } // MARK: - Event Handers @@ -218,15 +557,44 @@ public class Messaging: NSObject, Extension { /// - Parameters: /// - event: An `Event` to be processed func handleProcessEvent(_ event: Event) { - if event.data == nil { + guard event.data != nil else { Log.debug(label: MessagingConstants.LOG_TAG, "Process event handling ignored as event does not have any data - `\(event.id)`.") return } + // hard dependency on configuration shared state + guard getSharedState(extensionName: MessagingConstants.SharedState.Configuration.NAME, event: event)?.value != nil else { + Log.debug(label: MessagingConstants.LOG_TAG, "Event processing is paused, waiting for valid configuration - '\(event.id.uuidString)'.") + return + } + + // handle an event to request propositions from the remote + if event.isUpdatePropositionsEvent { + Log.debug(label: MessagingConstants.LOG_TAG, "Processing request to update propositions from the remote.") + fetchMessages(event, for: event.surfaces ?? []) + return + } + + // handle an event to get cached message feeds in the SDK + if event.isGetPropositionsEvent { + Log.debug(label: MessagingConstants.LOG_TAG, "Processing request to get message propositions cached in the SDK.") + // Queue the get propositions event in internal events queue to ensure any prior update requests are completed + // before it is processed. + eventsQueue.add(event) + return + } + + // handle an event to track propositions + if event.isTrackPropositionsEvent { + Log.debug(label: MessagingConstants.LOG_TAG, "Processing request to track propositions.") + trackMessages(event) + return + } + // handle an event for refreshing in-app messages from the remote if event.isRefreshMessageEvent { Log.debug(label: MessagingConstants.LOG_TAG, "Processing manual request to refresh In-App Message definitions from the remote.") - fetchMessages() + fetchMessages(event) return } @@ -277,15 +645,24 @@ public class Messaging: NSObject, Extension { } } + func propositionInfoFor(messageId: String) -> PropositionInfo? { + propositionInfo[messageId] + } + #if DEBUG - /// Used for testing only - func setMessagesRequestEventId(_ newId: String?) { - messagesRequestEventId = newId + /// For testing purposes only + func propositionInfoCount() -> Int { + propositionInfo.count + } + + /// For testing purposes only + func inMemoryPropositionsCount() -> Int { + propositions.count } /// Used for testing only - func setLastProcessedRequestEventId(_ newId: String?) { - lastProcessedRequestEventId = newId + func setRequestedSurfacesforEventId(_ eventId: String, expectedSurfaces: [Surface]) { + requestedSurfacesForEventId[eventId] = expectedSurfaces } #endif } diff --git a/AEPMessaging/Sources/MessagingConstants.swift b/AEPMessaging/Sources/MessagingConstants.swift index 6cc8979e..84b0bf98 100644 --- a/AEPMessaging/Sources/MessagingConstants.swift +++ b/AEPMessaging/Sources/MessagingConstants.swift @@ -16,21 +16,38 @@ enum MessagingConstants { static let LOG_TAG = "Messaging" static let EXTENSION_NAME = "com.adobe.messaging" - static let EXTENSION_VERSION = "4.1.1" + static let EXTENSION_VERSION = "5.0.0" static let FRIENDLY_NAME = "Messaging" static let RULES_ENGINE_NAME = EXTENSION_NAME + ".rulesengine" + static let FEED_RULES_ENGINE_NAME = EXTENSION_NAME + "Feed" + ".rulesengine" static let THIRTY_DAYS_IN_SECONDS = TimeInterval(60 * 60 * 24 * 30) + static let PATH_SEPARATOR = "/" + + enum ContentTypes { + static let APPLICATION_JSON = "application/json" + static let TEXT_HTML = "text/html" + static let TEXT_XML = "text/xml" + static let TEXT_PLAIN = "text/plain" + } enum Caches { static let CACHE_NAME = "com.adobe.messaging.cache" - static let MESSAGES = "messages" static let PROPOSITIONS = "propositions" - static let MESSAGES_DELIMITER = "||" static let PATH = "PATH" } enum ConsequenceTypes { - static let IN_APP_MESSAGE = "cjmiam" + static let SCHEMA = "schema" + } + + enum PersonalizationSchemas { + static let HTML_CONTENT = "https://ns.adobe.com/personalization/html-content-item" + static let JSON_CONTENT = "https://ns.adobe.com/personalization/json-content-item" + static let RULESET_ITEM = "https://ns.adobe.com/personalization/ruleset-item" + static let DEFAULT_CONTENT = "https://ns.adobe.com/personalization/default-content-item" + static let IN_APP = "https://ns.adobe.com/personalization/message/in-app" + static let FEED_ITEM = "https://ns.adobe.com/personalization/message/feed-item" + static let NATIVE_ALERT = "https://ns.adobe.com/personalization/message/native-alert" } enum Event { @@ -41,22 +58,31 @@ enum MessagingConstants { static let PUSH_TRACKING_EDGE = "Push tracking edge event" static let REFRESH_MESSAGES = "Refresh in-app messages" static let RETRIEVE_MESSAGE_DEFINITIONS = "Retrieve message definitions" + static let UPDATE_PROPOSITIONS = "Update propositions" + static let GET_PROPOSITIONS = "Get propositions" + static let TRACK_PROPOSITIONS = "Track propositions" + static let MESSAGE_PROPOSITIONS_RESPONSE = "Message propositions response" + static let MESSAGE_PROPOSITIONS_NOTIFICATION = "Message propositions notification" + static let FINALIZE_PROPOSITIONS_RESPONSE = "Finalize propositions response" static let PUSH_TRACKING_STATUS = "Push tracking status event" + static let EVENT_HISTORY_WRITE = "Write IAM event to history" } enum Source { + static let EVENT_HISTORY_WRITE = "com.adobe.eventSource.eventHistoryWrite" static let PERSONALIZATION_DECISIONS = "personalization:decisions" } - enum EventType { - static let messaging = "com.adobe.eventType.messaging" - } - enum Data { + enum AdobeKeys { + static let NAMESPACE = "__adobe" + static let AJO = "ajo" + static let INAPP_RESPONSE_FORMAT = "in-app-response-format" + } + enum Key { static let PUSH_IDENTIFIER = "pushidentifier" static let EVENT_TYPE = "eventType" - static let MESSAGE_ID = "id" static let APPLICATION_OPENED = "applicationOpened" static let ACTION_ID = "actionId" static let REFRESH_MESSAGES = "refreshmessages" @@ -64,36 +90,32 @@ enum MessagingConstants { static let ADOBE_XDM = "adobe_xdm" static let REQUEST_EVENT_ID = "requestEventId" static let IAM_HISTORY = "iam" + static let UPDATE_PROPOSITIONS = "updatepropositions" + static let GET_PROPOSITIONS = "getpropositions" + static let TRACK_PROPOSITIONS = "trackpropositions" + static let PROPOSITION_INTERACTION = "propositioninteraction" + static let SURFACES = "surfaces" + static let PROPOSITIONS = "propositions" + static let RESPONSE_ERROR = "responseerror" + static let ENDING_EVENT_ID = "endingEventId" static let PUSH_NOTIFICATION_TRACKING_STATUS = "pushTrackingStatus" static let PUSH_NOTIFICATION_TRACKING_MESSAGE = "pushTrackingStatusMessage" - static let TRIGGERED_CONSEQUENCE = "triggeredconsequence" static let ID = "id" static let DETAIL = "detail" static let TYPE = "type" - static let SOURCE = "source" + static let SCHEMA = "schema" + static let DATA = "data" + + enum Feed { + static let SURFACE = "surface" + } // In-App Messages enum IAM { - static let ID = "id" - static let TEMPLATE = "template" - static let HTML = "html" static let REMOTE_ASSETS = "remoteAssets" - static let TITLE = "title" - static let CONTENT = "content" - static let CONFIRM = "confirm" - static let CANCEL = "cancel" - static let URL = "url" - static let WAIT = "wait" - static let DATE = "date" - static let DEEPLINK = "adb_deeplink" - static let USER_DATA = "userData" - static let CATEGORY = "category" - static let SOUND = "sound" // layout keys - static let MOBILE_PARAMETERS = "mobileParameters" - static let SCHEMA_VERSION = "schemaVersion" static let WIDTH = "width" static let HEIGHT = "height" static let VERTICAL_ALIGN = "verticalAlign" @@ -104,7 +126,6 @@ enum MessagingConstants { static let DISPLAY_ANIMATION = "displayAnimation" static let DISMISS_ANIMATION = "dismissAnimation" static let GESTURES = "gestures" - static let BODY = "body" static let BACKDROP_COLOR = "backdropColor" static let BACKDROP_OPACITY = "backdropOpacity" static let CORNER_RADIUS = "cornerRadius" @@ -117,21 +138,6 @@ enum MessagingConstants { static let ID = "id" } } - - enum Values { - enum IAM { - // template values - static let FULLSCREEN = "fullscreen" - static let LOCAL = "local" - - // layout values - static let SWIPE_UP = "swipeUp" - static let SWIPE_DOWN = "swipeDown" - static let SWIPE_LEFT = "swipeLeft" - static let SWIPE_RIGHT = "swipeRight" - static let TAP_BACKGROUND = "tapBackground" - } - } } enum History { @@ -159,11 +165,6 @@ enum MessagingConstants { static let LINK = "link" static let ANIMATE = "animate" } - - enum Plist { - static let ACTIVITY_ID = "MESSAGING_ACTIVITY_ID" - static let PLACEMENT_ID = "MESSAGING_PLACEMENT_ID" - } } enum XDM { @@ -173,14 +174,18 @@ enum MessagingConstants { static let MIXINS = "mixins" static let EXPERIENCE = "_experience" static let CUSTOMER_JOURNEY_MANAGEMENT = "customerJourneyManagement" - static let MESSAGE_EXECUTION = "messageExecution" - static let MESSAGE_EXECUTION_ID = "messageExecutionID" static let APPLICATION = "application" static let LAUNCHES = "launches" static let LAUNCHES_VALUE = "value" - static let MESSAGE_PROFILE_JSON = "{\n \"messageProfile\":" + - "{\n \"channel\": {\n \"_id\": \"https://ns.adobe.com/xdm/channels/push\"\n }\n }" + - ",\n \"pushChannelContext\": {\n \"platform\": \"apns\"\n }\n}" + + /// messageProfile for push tracking in AJO + static let MESSAGE_PROFILE = "messageProfile" + static let CHANNEL = "channel" + static let _ID = "_id" + static let PUSH_CHANNEL_ID = "https://ns.adobe.com/xdm/channels/push" + static let PUSH_CHANNEL_CONTEXT = "pushChannelContext" + static let PLATFORM = "platform" + static let APNS = "apns" } enum Key { @@ -196,9 +201,11 @@ enum MessagingConstants { static let EVENT_TYPE = "eventType" static let PUSH_NOTIFICATION_TRACKING = "pushNotificationTracking" static let DATA = "data" + static let REQUEST = "request" + static let SEND_COMPLETION = "sendCompletion" } - enum IAM { + enum Inbound { static let SURFACE_BASE = "mobileapp://" enum EventType { @@ -220,6 +227,7 @@ enum MessagingConstants { static let PERSONALIZATION = "personalization" static let QUERY = "query" static let SURFACES = "surfaces" + static let SCHEMAS = "schemas" static let DECISIONING = "decisioning" static let PROPOSITION_ACTION = "propositionAction" static let LABEL = "label" @@ -228,18 +236,14 @@ enum MessagingConstants { static let ID = "id" static let SCOPE = "scope" static let SCOPE_DETAILS = "scopeDetails" + static let ITEMS = "items" static let CHARACTERISTICS = "characteristics" - static let CJM_XDM = "cjmXdm" - static let IN_APP_MESSAGE_TRACKING = "inappMessageTracking" - static let ACTION = "action" + static let TOKENS = "tokens" } enum Value { - static let TRIGGERED = "triggered" - static let DISPLAYED = "displayed" - static let CLICKED = "clicked" - static let DISMISSED = "dismissed" - static let EMPTY_CONTENT = "{}" + /// enum (int) representing desired format returned by XAS for in-app message propositions + static let IAM_RESPONSE_FORMAT = 2 } } @@ -268,15 +272,12 @@ enum MessagingConstants { } enum SharedState { - static let stateOwner = "stateowner" - enum Messaging { static let PUSH_IDENTIFIER = "pushidentifier" } enum Configuration { static let NAME = "com.adobe.module.configuration" - static let EXPERIENCE_CLOUD_ORG = "experienceCloud.org" // Messaging dataset ids static let EXPERIENCE_EVENT_DATASET = "messaging.eventDataset" diff --git a/AEPMessaging/Sources/MessagingEdgeEventType.swift b/AEPMessaging/Sources/MessagingEdgeEventType.swift index 77a48b0a..c2e42cd9 100644 --- a/AEPMessaging/Sources/MessagingEdgeEventType.swift +++ b/AEPMessaging/Sources/MessagingEdgeEventType.swift @@ -15,47 +15,66 @@ import Foundation /// Provides mapping to XDM EventType strings needed for Experience Event requests @objc(AEPMessagingEdgeEventType) public enum MessagingEdgeEventType: Int { - case inappDismiss = 0 - case inappInteract = 1 - case inappTrigger = 2 - case inappDisplay = 3 case pushApplicationOpened = 4 case pushCustomAction = 5 + case dismiss = 6 + case interact = 7 + case trigger = 8 + case display = 9 public func toString() -> String { switch self { - case .inappDismiss: - return MessagingConstants.XDM.IAM.EventType.DISMISS - case .inappTrigger: - return MessagingConstants.XDM.IAM.EventType.TRIGGER - case .inappInteract: - return MessagingConstants.XDM.IAM.EventType.INTERACT - case .inappDisplay: - return MessagingConstants.XDM.IAM.EventType.DISPLAY + case .dismiss: + return MessagingConstants.XDM.Inbound.EventType.DISMISS + case .trigger: + return MessagingConstants.XDM.Inbound.EventType.TRIGGER + case .interact: + return MessagingConstants.XDM.Inbound.EventType.INTERACT + case .display: + return MessagingConstants.XDM.Inbound.EventType.DISPLAY case .pushCustomAction: return MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION case .pushApplicationOpened: return MessagingConstants.XDM.Push.EventType.APPLICATION_OPENED } } + + /// Initializes `MessagingEdgeEventType` with the provided type string. + /// - Parameter type: Event type string + init?(fromType type: String) { + switch type { + case MessagingConstants.XDM.Inbound.EventType.DISMISS: + self = .dismiss + case MessagingConstants.XDM.Inbound.EventType.TRIGGER: + self = .trigger + case MessagingConstants.XDM.Inbound.EventType.INTERACT: + self = .interact + case MessagingConstants.XDM.Inbound.EventType.DISPLAY: + self = .display + case MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION: + self = .pushCustomAction + case MessagingConstants.XDM.Push.EventType.APPLICATION_OPENED: + self = .pushApplicationOpened + default: + return nil + } + } } extension MessagingEdgeEventType { /// Used to generate `propositionEventType` payload in outgoing proposition interaction events var propositionEventType: String { switch self { - case .inappDismiss: - return MessagingConstants.XDM.IAM.PropositionEventType.DISMISS - case .inappInteract: - return MessagingConstants.XDM.IAM.PropositionEventType.INTERACT - case .inappTrigger: - return MessagingConstants.XDM.IAM.PropositionEventType.TRIGGER - case .inappDisplay: - return MessagingConstants.XDM.IAM.PropositionEventType.DISPLAY + case .dismiss: + return MessagingConstants.XDM.Inbound.PropositionEventType.DISMISS + case .interact: + return MessagingConstants.XDM.Inbound.PropositionEventType.INTERACT + case .trigger: + return MessagingConstants.XDM.Inbound.PropositionEventType.TRIGGER + case .display: + return MessagingConstants.XDM.Inbound.PropositionEventType.DISPLAY case .pushApplicationOpened, .pushCustomAction: return "" - default: - return "" } } } diff --git a/AEPMessaging/Sources/MessagingMigrator.swift b/AEPMessaging/Sources/MessagingMigrator.swift new file mode 100644 index 00000000..4fc5766f --- /dev/null +++ b/AEPMessaging/Sources/MessagingMigrator.swift @@ -0,0 +1,48 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +enum MessagingMigrator { + static func migrate(cache: Cache) { + guard let cachedPropositions = cache.get(key: MessagingConstants.Caches.PROPOSITIONS) else { + Log.trace(label: MessagingConstants.LOG_TAG, "Unable to load cached messages, cache file not found.") + return + } + + let decoder = JSONDecoder() + guard let propositions: [PropositionPayload] = try? decoder.decode([PropositionPayload].self, from: cachedPropositions.data) else { + Log.trace(label: MessagingConstants.LOG_TAG, "Unable to decode cached messages.") + return + } + + let mappedPropositions = propositions.map { $0.convertToProposition() } + guard !mappedPropositions.isEmpty else { + return + } + + let encoder = JSONEncoder() + guard let cacheData = try? encoder.encode(mappedPropositions.toDictionary { $0.scope }) else { + Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache, unable to encode proposition.") + return + } + + let cacheEntry = CacheEntry(data: cacheData, expiry: .never, metadata: nil) + do { + try cache.set(key: MessagingConstants.Caches.PROPOSITIONS, entry: cacheEntry) + Log.trace(label: MessagingConstants.LOG_TAG, "In-app messaging cache has been created.") + } catch { + Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache: \(error).") + } + } +} diff --git a/AEPMessaging/Sources/MessagingRulesEngine+Caching.swift b/AEPMessaging/Sources/MessagingRulesEngine+Caching.swift index 3689938a..e96337dc 100644 --- a/AEPMessaging/Sources/MessagingRulesEngine+Caching.swift +++ b/AEPMessaging/Sources/MessagingRulesEngine+Caching.swift @@ -44,58 +44,4 @@ extension MessagingRulesEngine { } } } - - // MARK: - proposition caching - - /// Loads propositions from persistence into memory then hydrates the messaging rules engine - func loadCachedPropositions(for expectedScope: String) { - guard let cachedPropositions = cache.get(key: MessagingConstants.Caches.PROPOSITIONS) else { - Log.trace(label: MessagingConstants.LOG_TAG, "Unable to load cached messages - cache file not found.") - return - } - - let decoder = JSONDecoder() - guard let propositions: [PropositionPayload] = try? decoder.decode([PropositionPayload].self, from: cachedPropositions.data) else { - return - } - - Log.trace(label: MessagingConstants.LOG_TAG, "Loading in-app message definition from cache.") - loadPropositions(propositions, clearExisting: false, persistChanges: false, expectedScope: expectedScope) - } - - func addPropositionsToCache(_ propositions: [PropositionPayload]?) { - guard let propositions = propositions, !propositions.isEmpty else { - return - } - - inMemoryPropositions.append(contentsOf: propositions) - cachePropositions(inMemoryPropositions) - } - - func cachePropositions(_ propositions: [PropositionPayload]?) { - // remove cached propositions if param is nil or empty - guard let propositions = propositions, !propositions.isEmpty else { - do { - try cache.remove(key: MessagingConstants.Caches.PROPOSITIONS) - Log.trace(label: MessagingConstants.LOG_TAG, "In-app messaging cache has been deleted.") - } catch let error as NSError { - Log.trace(label: MessagingConstants.LOG_TAG, "Unable to remove in-app messaging cache: \(error).") - } - - return - } - - let encoder = JSONEncoder() - guard let cacheData = try? encoder.encode(propositions) else { - Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache: unable to encode proposition.") - return - } - let cacheEntry = CacheEntry(data: cacheData, expiry: .never, metadata: nil) - do { - try cache.set(key: MessagingConstants.Caches.PROPOSITIONS, entry: cacheEntry) - Log.trace(label: MessagingConstants.LOG_TAG, "In-app messaging cache has been created.") - } catch { - Log.warning(label: MessagingConstants.LOG_TAG, "Error creating in-app messaging cache: \(error).") - } - } } diff --git a/AEPMessaging/Sources/MessagingRulesEngine.swift b/AEPMessaging/Sources/MessagingRulesEngine.swift index 05be61f7..7f807c9d 100644 --- a/AEPMessaging/Sources/MessagingRulesEngine.swift +++ b/AEPMessaging/Sources/MessagingRulesEngine.swift @@ -16,105 +16,28 @@ import Foundation /// Wrapper class around `LaunchRulesEngine` that provides a different implementation for loading rules class MessagingRulesEngine { - let rulesEngine: LaunchRulesEngine + let launchRulesEngine: LaunchRulesEngine let runtime: ExtensionRuntime let cache: Cache - var inMemoryPropositions: [PropositionPayload] = [] - var propositionInfo: [String: PropositionInfo] = [:] /// Initialize this class, creating a new rules engine with the provided name and runtime - init(name: String, extensionRuntime: ExtensionRuntime) { + init(name: String, extensionRuntime: ExtensionRuntime, cache: Cache) { runtime = extensionRuntime - rulesEngine = LaunchRulesEngine(name: name, - extensionRuntime: extensionRuntime) - cache = Cache(name: MessagingConstants.Caches.CACHE_NAME) + launchRulesEngine = LaunchRulesEngine(name: name, extensionRuntime: extensionRuntime) + self.cache = cache } /// INTERNAL ONLY /// Initializer to provide a mock rules engine for testing - init(extensionRuntime: ExtensionRuntime, rulesEngine: LaunchRulesEngine, cache: Cache) { + init(extensionRuntime: ExtensionRuntime, launchRulesEngine: LaunchRulesEngine, cache: Cache) { runtime = extensionRuntime - self.rulesEngine = rulesEngine + self.launchRulesEngine = launchRulesEngine self.cache = cache } /// if we have rules loaded, then we simply process the event. /// if rules are not yet loaded, add the event to the waitingEvents array to func process(event: Event) { - _ = rulesEngine.process(event: event) + _ = launchRulesEngine.process(event: event) } - - func loadPropositions(_ propositions: [PropositionPayload]?, clearExisting: Bool, persistChanges: Bool = true, expectedScope: String) { - var rules: [LaunchRule] = [] - var tempPropInfo: [String: PropositionInfo] = [:] - - if let propositions = propositions { - for proposition in propositions { - guard expectedScope == proposition.propositionInfo.scope else { - Log.debug(label: MessagingConstants.LOG_TAG, "Ignoring proposition where scope (\(proposition.propositionInfo.scope)) does not match expected scope (\(expectedScope)).") - continue - } - - guard let ruleString = proposition.items.first?.data.content, !ruleString.isEmpty else { - Log.debug(label: MessagingConstants.LOG_TAG, "Skipping proposition with no in-app message content.") - continue - } - - guard let rule = processRule(ruleString) else { - Log.debug(label: MessagingConstants.LOG_TAG, "Skipping proposition with malformed in-app message content.") - continue - } - - // pre-fetch the assets for this message if there are any defined - cacheRemoteAssetsFor(rule) - - // store reporting data for this payload for later use - if let messageId = rule.first?.consequences.first?.id { - tempPropInfo[messageId] = proposition.propositionInfo - } - - rules.append(contentsOf: rule) - } - } - - if clearExisting { - inMemoryPropositions.removeAll() - cachePropositions(nil) - propositionInfo = tempPropInfo - rulesEngine.replaceRules(with: rules) - Log.debug(label: MessagingConstants.LOG_TAG, "Successfully loaded \(rules.count) message(s) into the rules engine for scope '\(expectedScope)'.") - } else if !rules.isEmpty { - propositionInfo.merge(tempPropInfo) { _, new in new } - rulesEngine.addRules(rules) - Log.debug(label: MessagingConstants.LOG_TAG, "Successfully added \(rules.count) message(s) into the rules engine for scope '\(expectedScope)'.") - } else { - Log.trace(label: MessagingConstants.LOG_TAG, "Ignoring request to load in-app messages for scope '\(expectedScope)'. The propositions parameter provided was empty.") - } - - if persistChanges { - addPropositionsToCache(propositions) - } else { - inMemoryPropositions.append(contentsOf: propositions ?? []) - } - } - - func processRule(_ rule: String) -> [LaunchRule]? { - JSONRulesParser.parse(rule.data(using: .utf8) ?? Data(), runtime: runtime) - } - - func propositionInfoForMessageId(_ messageId: String) -> PropositionInfo? { - propositionInfo[messageId] - } - - #if DEBUG - /// For testing purposes only - func propositionInfoCount() -> Int { - propositionInfo.count - } - - /// For testing purposes only - func inMemoryPropositionsCount() -> Int { - inMemoryPropositions.count - } - #endif } diff --git a/AEPMessaging/Sources/ParsedPropositions.swift b/AEPMessaging/Sources/ParsedPropositions.swift new file mode 100644 index 00000000..224ed683 --- /dev/null +++ b/AEPMessaging/Sources/ParsedPropositions.swift @@ -0,0 +1,110 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +struct ParsedPropositions { + weak var runtime: ExtensionRuntime? + + // store tracking information for propositions loaded into rules engines + var propositionInfoToCache: [String: PropositionInfo] = [:] + + // non-in-app propositions should be cached and not persisted + var propositionsToCache: [Surface: [Proposition]] = [:] + + // in-app propositions don't need to stay in cache, but must be persisted + // also need to store tracking info for in-app propositions as `PropositionInfo` + var propositionsToPersist: [Surface: [Proposition]] = [:] + + // in-app and feed rules that need to be applied to their respective rules engines + var surfaceRulesBySchemaType: [SchemaType: [Surface: [LaunchRule]]] = [:] + + init(with propositions: [Surface: [Proposition]], requestedSurfaces: [Surface], runtime: ExtensionRuntime) { + self.runtime = runtime + for propositionsArray in propositions.values { + for proposition in propositionsArray { + guard let surface = requestedSurfaces.first(where: { $0.uri == proposition.scope }) else { + Log.debug(label: MessagingConstants.LOG_TAG, + "Ignoring proposition where scope (\(proposition.scope)) does not match one of the expected surfaces.") + continue + } + + // handle schema consequences which are representable as PropositionItems + guard let firstPropositionItem = proposition.items.first else { + continue + } + + switch firstPropositionItem.schema { + // - handle ruleset-item schemas + case .ruleset: + guard let parsedRules = parseRule(firstPropositionItem.itemData) else { + continue + } + guard let consequence = parsedRules.first?.consequences.first, + let schemaConsequence = PropositionItem.fromRuleConsequence(consequence) + else { + continue + } + + // handle these schemas when they're embedded in ruleset-item schemas: + // a. in-app schema consequences get persisted to disk, cached for reporting, and added to rules that need to be updated + // i. default-content schema consequences are treated like in-app at a proposition level + // b. feed schema consequences get cached for reporting added to rules that need to be updated + // + // IMPORTANT! - for schema consequences that are embedded in ruleset-items, the following is true: + // + // consequence.id == consequence.detail.id + // + // this is important because we need a reliable key to store and retrieve `PropositionInfo` for reporting + switch schemaConsequence.schema { + case .inapp, .defaultContent: + propositionInfoToCache[consequence.id] = PropositionInfo.fromProposition(proposition) + propositionsToPersist.add(proposition, forKey: surface) + mergeRules(parsedRules, for: surface, with: .inapp) + case .feed: + propositionInfoToCache[consequence.id] = PropositionInfo.fromProposition(proposition) + mergeRules(parsedRules, for: surface, with: .feed) + default: + continue + } + + // - handle json-content, html-content, and default-content schemas for code based experiences + // a. code based schemas are cached for reporting + case .jsonContent, .htmlContent, .defaultContent: + propositionsToCache.add(proposition, forKey: surface) + case .unknown: + continue + default: + continue + } + } + } + } + + private func parseRule(_ rule: [String: Any]) -> [LaunchRule]? { + let ruleData = try? JSONSerialization.data(withJSONObject: rule, options: .prettyPrinted) + return JSONRulesParser.parse(ruleData ?? Data(), runtime: runtime) + } + + private mutating func mergeRules(_ rules: [LaunchRule], for surface: Surface, with schemaType: SchemaType) { + // get rules we may already have for this schemaType + var tempRulesBySchemaType = surfaceRulesBySchemaType[schemaType] ?? [:] + + // combine rules with existing + tempRulesBySchemaType.addArray(rules, forKey: surface) + + // apply up to surfaceRulesBySchemaType + surfaceRulesBySchemaType[schemaType] = tempRulesBySchemaType + } +} diff --git a/AEPMessaging/Sources/Proposition.swift b/AEPMessaging/Sources/Proposition.swift new file mode 100644 index 00000000..374ceadd --- /dev/null +++ b/AEPMessaging/Sources/Proposition.swift @@ -0,0 +1,74 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +@objc(AEPProposition) +@objcMembers +public class Proposition: NSObject, Codable { + /// Unique proposition identifier + public let uniqueId: String + + /// Scope string + public let scope: String + + /// Scope details dictionary + var scopeDetails: [String: Any] + + /// Array containing proposition decision items + private let propositionItems: [PropositionItem] + + public lazy var items: [PropositionItem] = { + for item in propositionItems { + item.proposition = self + } + return propositionItems + }() + + enum CodingKeys: String, CodingKey { + case id + case scope + case scopeDetails + case items + } + + init(uniqueId: String, scope: String, scopeDetails: [String: Any], items: [PropositionItem]) { + self.uniqueId = uniqueId + self.scope = scope + self.scopeDetails = scopeDetails + propositionItems = items + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + uniqueId = try container.decode(String.self, forKey: .id) + scope = try container.decode(String.self, forKey: .scope) + let codableScopeDetails = try? container.decode([String: AnyCodable].self, forKey: .scopeDetails) + scopeDetails = AnyCodable.toAnyDictionary(dictionary: codableScopeDetails) ?? [:] + guard !scopeDetails.isEmpty else { + throw DecodingError.dataCorruptedError(forKey: CodingKeys.scopeDetails, in: container, debugDescription: "Scope details is corrupted and cannot be decoded.") + } + let tempItems = (try? container.decode([PropositionItem].self, forKey: .items)) + propositionItems = tempItems ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(uniqueId, forKey: .id) + try container.encode(scope, forKey: .scope) + try container.encode(AnyCodable.from(dictionary: scopeDetails), forKey: .scopeDetails) + try container.encode(items, forKey: .items) + } +} diff --git a/AEPMessaging/Sources/PropositionInfo.swift b/AEPMessaging/Sources/PropositionInfo.swift index f29716fa..e3a9616d 100644 --- a/AEPMessaging/Sources/PropositionInfo.swift +++ b/AEPMessaging/Sources/PropositionInfo.swift @@ -30,4 +30,10 @@ extension PropositionInfo { } return activity[MessagingConstants.Event.Data.Key.Personalization.ID] as? String ?? "" } + + static func fromProposition(_ proposition: Proposition) -> PropositionInfo { + PropositionInfo(id: proposition.uniqueId, + scope: proposition.scope, + scopeDetails: AnyCodable.from(dictionary: proposition.scopeDetails) ?? [:]) + } } diff --git a/AEPMessaging/Sources/PropositionInteraction.swift b/AEPMessaging/Sources/PropositionInteraction.swift new file mode 100644 index 00000000..7550a564 --- /dev/null +++ b/AEPMessaging/Sources/PropositionInteraction.swift @@ -0,0 +1,132 @@ +/* + 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 AEPServices +import Foundation + +/// `PropositionInteraction` is a container for tracking related information needed to dispatch a +/// `decisioning.propositionDisplay` or `decisioning.propositionInteract` event to the Experience Edge. +struct PropositionInteraction: Codable { + /// Edge event type represented by enum `MessagingEdgeEventType` + var eventType: MessagingEdgeEventType + + /// Interaction string to identify interaction type with the proposition item + var interaction: String? + + /// `PropositionInfo` instance to encapsulate proposition related information + var propositionInfo: PropositionInfo + + /// Item ID string to identity the proposition item interacted with + var itemId: String? + + /// Sub-item tokens array to track interactions with proposition sub-items + var tokens: [String]? + + /// Proposition interaction XDM + var xdm: [String: Any] { + var propositionDetailsData: [String: Any] = [:] + + propositionDetailsData = [ + MessagingConstants.XDM.Inbound.Key.ID: propositionInfo.id, + MessagingConstants.XDM.Inbound.Key.SCOPE: propositionInfo.scope, + MessagingConstants.XDM.Inbound.Key.SCOPE_DETAILS: propositionInfo.scopeDetails.asDictionary() ?? [:] + ] + + if + let itemId = itemId, + !itemId.isEmpty { + var itemDict: [String: Any] = [ + MessagingConstants.XDM.Inbound.Key.ID: itemId + ] + + if let tokens = tokens, !tokens.isEmpty { + itemDict[MessagingConstants.XDM.Inbound.Key.CHARACTERISTICS] = [ + MessagingConstants.XDM.Inbound.Key.TOKENS: tokens.joined(separator: ",") + ] + } + + propositionDetailsData[MessagingConstants.XDM.Inbound.Key.ITEMS] = [itemDict] + } + + let propositionEventType: [String: Any] = [ + eventType.propositionEventType: 1 + ] + + var decisioning: [String: Any] = [ + MessagingConstants.XDM.Inbound.Key.PROPOSITION_EVENT_TYPE: propositionEventType, + MessagingConstants.XDM.Inbound.Key.PROPOSITIONS: [propositionDetailsData] + ] + + // only add `propositionAction` data if this is an interact event + if + eventType == .interact, + let interaction = interaction { + let propositionAction: [String: String] = [ + MessagingConstants.XDM.Inbound.Key.ID: interaction, + MessagingConstants.XDM.Inbound.Key.LABEL: interaction + ] + decisioning[MessagingConstants.XDM.Inbound.Key.PROPOSITION_ACTION] = propositionAction + } + + let experience: [String: Any] = [ + MessagingConstants.XDM.Inbound.Key.DECISIONING: decisioning + ] + + let xdm: [String: Any] = [ + MessagingConstants.XDM.Key.EVENT_TYPE: eventType.toString(), + MessagingConstants.XDM.AdobeKeys.EXPERIENCE: experience + ] + return xdm + } + + enum CodingKeys: String, CodingKey { + case eventType + case interaction + case propositionInfo + case itemId + case tokens + } + + init(eventType: MessagingEdgeEventType, interaction: String?, propositionInfo: PropositionInfo, itemId: String?, tokens: [String]?) { + self.eventType = eventType + self.interaction = interaction + self.propositionInfo = propositionInfo + self.itemId = itemId + self.tokens = tokens + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(eventType.toString(), forKey: .eventType) + try container.encode(interaction, forKey: .interaction) + try container.encode(propositionInfo, forKey: .propositionInfo) + try container.encode(itemId, forKey: .itemId) + try container.encode(tokens, forKey: .tokens) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard + let eventTypeString = try? container.decode(String.self, forKey: .eventType), + let edgeEventType = MessagingEdgeEventType(fromType: eventTypeString) + else { + throw DecodingError.dataCorruptedError(forKey: CodingKeys.eventType, in: container, debugDescription: "Cannot create MessagingEdgeEventType from the provided event type string.") + } + eventType = edgeEventType + interaction = try container.decodeIfPresent(String.self, forKey: .interaction) + propositionInfo = try container.decode(PropositionInfo.self, forKey: .propositionInfo) + itemId = try container.decodeIfPresent(String.self, forKey: .itemId) + tokens = try container.decodeIfPresent([String].self, forKey: .tokens) + } +} diff --git a/AEPMessaging/Sources/PropositionItem.swift b/AEPMessaging/Sources/PropositionItem.swift new file mode 100644 index 00000000..6e31dc25 --- /dev/null +++ b/AEPMessaging/Sources/PropositionItem.swift @@ -0,0 +1,184 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +/// A `PropositionItem` object represents a personalization JSON object returned by Konductor +/// In its JSON form, it has the following properties: +/// - `id` +/// - `schema` +/// - `data` +/// This contents of `data` will be determined by the provided `schema`. +/// This class provides helper access to get strongly typed content - e.g. `getTypedData` +@objc(AEPPropositionItem) +@objcMembers +public class PropositionItem: NSObject, Codable { + /// Unique identifier for this `PropositionItem` + /// contains value for `id` in JSON + public let itemId: String + + /// `PropositionItem` schema string + /// contains value for `schema` in JSON + public let schema: SchemaType + + /// `PropositionItem` data as dictionary + /// contains value for `data` in JSON + public let itemData: [String: Any] + + /// Weak reference to Proposition instance + weak var proposition: Proposition? + + enum CodingKeys: String, CodingKey { + case id + case schema + case data + } + + init(itemId: String, schema: SchemaType, itemData: [String: Any]) { + self.itemId = itemId + self.schema = schema + self.itemData = itemData + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + itemId = try container.decode(String.self, forKey: .id) + schema = try SchemaType(from: container.decode(String.self, forKey: .schema)) + let codableItemData = try container.decode([String: AnyCodable].self, forKey: .data) + itemData = AnyCodable.toAnyDictionary(dictionary: codableItemData) ?? [:] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(itemId, forKey: .id) + try container.encode(schema.toString(), forKey: .schema) + try container.encode(AnyCodable.from(dictionary: itemData), forKey: .data) + } +} + +public extension PropositionItem { + /// Tracks interaction with the given proposition item. + /// + /// - Parameters + /// - interaction: a custom string value describing the interaction. + /// - eventType: an enum specifying event type for the interaction. + /// - tokens: an array containing the sub-item tokens for recording interaction. + func track(_ interaction: String? = nil, withEdgeEventType eventType: MessagingEdgeEventType, forTokens tokens: [String]? = nil) { + guard let propositionInteractionXdm = generateInteractionXdm(interaction, withEdgeEventType: eventType, forTokens: tokens) else { + Log.debug(label: MessagingConstants.LOG_TAG, + "Cannot track proposition interaction for item \(itemId), could not generate interactions XDM.") + return + } + + let eventData: [String: Any] = [ + MessagingConstants.Event.Data.Key.TRACK_PROPOSITIONS: true, + MessagingConstants.Event.Data.Key.PROPOSITION_INTERACTION: propositionInteractionXdm + ] + + let event = Event(name: MessagingConstants.Event.Name.TRACK_PROPOSITIONS, + type: EventType.messaging, + source: EventSource.requestContent, + data: eventData) + + MobileCore.dispatch(event: event) + } + + /// Creates a dictionary containing XDM data for interaction with the given proposition item, for the provided event type. + /// + /// If the proposition reference within the item is released and no longer valid, the method returns `nil`. + /// + /// - Parameters + /// - interaction: a custom string value describing the interaction. + /// - eventType: an enum specifying event type for the interaction. + /// - tokens: an array containing the sub-item tokens for recording interaction. + /// - Returns A dictionary containing XDM data for the propositon interaction. + func generateInteractionXdm(_ interaction: String? = nil, withEdgeEventType eventType: MessagingEdgeEventType, forTokens tokens: [String]? = nil) -> [String: Any]? { + guard let proposition = proposition else { + Log.debug(label: MessagingConstants.LOG_TAG, + "Cannot generate interaction XDM for item \(itemId), proposition reference is not available.") + return nil + } + + return PropositionInteraction(eventType: eventType, interaction: interaction, propositionInfo: PropositionInfo.fromProposition(proposition), itemId: itemId, tokens: tokens).xdm + } + + static func fromRuleConsequence(_ consequence: RuleConsequence) -> PropositionItem? { + guard let detailsData = try? JSONSerialization.data(withJSONObject: consequence.details, options: .prettyPrinted) else { + return nil + } + return try? JSONDecoder().decode(PropositionItem.self, from: detailsData) + } + + static func fromRuleConsequenceEvent(_ event: Event) -> PropositionItem? { + guard let id = event.schemaId, let schema = event.schemaType, let schemaData = event.schemaData else { + return nil + } + + return PropositionItem(itemId: id, schema: schema, itemData: schemaData) + } + + var jsonContentDictionary: [String: Any]? { + guard schema == .jsonContent, let jsonItem = getTypedData(JsonContentSchemaData.self) else { + return nil + } + + return jsonItem.getDictionaryValue + } + + var jsonContentArray: [Any]? { + guard schema == .jsonContent, let jsonItem = getTypedData(JsonContentSchemaData.self) else { + return nil + } + + return jsonItem.getArrayValue + } + + var htmlContent: String? { + guard schema == .htmlContent, let htmlItem = getTypedData(HtmlContentSchemaData.self) else { + return nil + } + + return htmlItem.content + } + + var inappSchemaData: InAppSchemaData? { + guard schema == .inapp else { + return nil + } + return getTypedData(InAppSchemaData.self) + } + + var feedItemSchemaData: FeedItemSchemaData? { + guard schema == .feed else { + return nil + } + return getTypedData(FeedItemSchemaData.self) + } + + private func getTypedData(_ type: T.Type) -> T? where T: Decodable { + guard let itemDataAsData = try? JSONSerialization.data(withJSONObject: itemData) + else { + Log.debug(label: MessagingConstants.LOG_TAG, "Unable to get typed data for proposition item - could not convert 'data' field to type 'Data'.") + return nil + } + do { + return try JSONDecoder().decode(type, from: itemDataAsData) + } catch { + Log.warning(label: MessagingConstants.LOG_TAG, "An error occurred while decoding a PropositionItem: \(error)") + return nil + } + } +} diff --git a/AEPMessaging/Sources/PropositionPayload.swift b/AEPMessaging/Sources/PropositionPayload.swift index 7d4c4f39..13415e6b 100644 --- a/AEPMessaging/Sources/PropositionPayload.swift +++ b/AEPMessaging/Sources/PropositionPayload.swift @@ -15,7 +15,7 @@ import Foundation struct PropositionPayload: Codable { var propositionInfo: PropositionInfo - var items: [PayloadItem] + var items: [PropositionItem] enum CodingKeys: String, CodingKey { case id @@ -31,7 +31,7 @@ struct PropositionPayload: Codable { let scopeDetails = try values.decode([String: AnyCodable].self, forKey: .scopeDetails) propositionInfo = PropositionInfo(id: id, scope: scope, scopeDetails: scopeDetails) - items = try values.decode([PayloadItem].self, forKey: .items) + items = try values.decode([PropositionItem].self, forKey: .items) } func encode(to encoder: Encoder) throws { @@ -43,8 +43,19 @@ struct PropositionPayload: Codable { } /// internal use only for testing - init(propositionInfo: PropositionInfo, items: [PayloadItem]) { + init(propositionInfo: PropositionInfo, items: [PropositionItem]) { self.propositionInfo = propositionInfo self.items = items } + + func convertToProposition() -> Proposition { + var propItems: [PropositionItem] = [] + for item in items { + propItems.append(PropositionItem(itemId: item.itemId, schema: item.schema, itemData: item.itemData)) + } + return Proposition(uniqueId: propositionInfo.id, + scope: propositionInfo.scope, + scopeDetails: AnyCodable.toAnyDictionary(dictionary: propositionInfo.scopeDetails) ?? [:], + items: propItems) + } } diff --git a/AEPMessaging/Sources/PayloadItem.swift b/AEPMessaging/Sources/RuleConsequence+Messaging.swift similarity index 57% rename from AEPMessaging/Sources/PayloadItem.swift rename to AEPMessaging/Sources/RuleConsequence+Messaging.swift index 3c32865b..ec1c0daf 100644 --- a/AEPMessaging/Sources/PayloadItem.swift +++ b/AEPMessaging/Sources/RuleConsequence+Messaging.swift @@ -1,5 +1,5 @@ /* - Copyright 2022 Adobe. All rights reserved. + Copyright 2023 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 @@ -10,10 +10,19 @@ governing permissions and limitations under the License. */ +import AEPCore import Foundation -struct PayloadItem: Codable { - var id: String? - var schema: String? - var data: ItemData +extension RuleConsequence { + var isFeedItem: Bool { + detailSchema == MessagingConstants.PersonalizationSchemas.FEED_ITEM + } + + var isInApp: Bool { + detailSchema == MessagingConstants.PersonalizationSchemas.IN_APP + } + + private var detailSchema: String { + details[MessagingConstants.Event.Data.Key.SCHEMA] as? String ?? "" + } } diff --git a/AEPMessaging/Sources/Surface.swift b/AEPMessaging/Sources/Surface.swift new file mode 100644 index 00000000..7b579c9d --- /dev/null +++ b/AEPMessaging/Sources/Surface.swift @@ -0,0 +1,60 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +@objc(AEPSurface) +@objcMembers +public class Surface: NSObject, Codable { + /// Unique surface URI string + public let uri: String + + var isValid: Bool { + guard URL(string: uri) != nil else { + Log.warning(label: MessagingConstants.LOG_TAG, + "Invalid surface URI found \(uri).") + return false + } + return true + } + + public init(path: String) { + let baseUri = Bundle.main.mobileappSurface + guard !path.isEmpty else { + uri = baseUri + return + } + uri = baseUri + MessagingConstants.PATH_SEPARATOR + path + } + + init(uri: String) { + self.uri = uri + } + + override public convenience init() { + self.init(uri: Bundle.main.mobileappSurface) + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let rhs = object as? Surface else { + return false + } + return uri == rhs.uri + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(uri) + return hasher.finalize() + } +} diff --git a/AEPMessaging/Sources/schemas/ContentType.swift b/AEPMessaging/Sources/schemas/ContentType.swift new file mode 100644 index 00000000..378b9ef9 --- /dev/null +++ b/AEPMessaging/Sources/schemas/ContentType.swift @@ -0,0 +1,61 @@ +/* + Copyright 2023 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 Foundation + +/// Enum representing content types found within a schema. +@objc(AEPContentType) +public enum ContentType: Int, Codable { + case applicationJson = 0 + case textHtml = 1 + case textXml = 2 + case textPlain = 3 + case unknown = 4 + + /// Initializes ContentType with the provided content type string. + /// - Parameter contentType: content type string + init(from contentType: String) { + switch contentType { + case MessagingConstants.ContentTypes.APPLICATION_JSON: + self = .applicationJson + + case MessagingConstants.ContentTypes.TEXT_HTML: + self = .textHtml + + case MessagingConstants.ContentTypes.TEXT_XML: + self = .textXml + + case MessagingConstants.ContentTypes.TEXT_PLAIN: + self = .textPlain + + default: + self = .unknown + } + } + + /// Returns the content schema string of `ContentType`. + /// - Returns: A string representing the content type. + public func toString() -> String { + switch self { + case .applicationJson: + return MessagingConstants.ContentTypes.APPLICATION_JSON + case .textHtml: + return MessagingConstants.ContentTypes.TEXT_HTML + case .textXml: + return MessagingConstants.ContentTypes.TEXT_XML + case .textPlain: + return MessagingConstants.ContentTypes.TEXT_PLAIN + default: + return "" + } + } +} diff --git a/AEPMessaging/Sources/schemas/FeedItemSchemaData.swift b/AEPMessaging/Sources/schemas/FeedItemSchemaData.swift new file mode 100644 index 00000000..e3ec5099 --- /dev/null +++ b/AEPMessaging/Sources/schemas/FeedItemSchemaData.swift @@ -0,0 +1,89 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +/// represents the schema data object for a feed item schema +@objc(AEPFeedItemSchemaData) +@objcMembers +public class FeedItemSchemaData: NSObject, Codable { + public let content: Any + public let contentType: ContentType + public let publishedDate: Int? + public let expiryDate: Int? + public let meta: [String: Any]? + + enum CodingKeys: String, CodingKey { + case content + case contentType + case publishedDate + case expiryDate + case meta + } + + /// ONLY USED FOR TESTING + override private init() { + content = "plain-text content" + contentType = .textPlain + publishedDate = nil + expiryDate = nil + meta = nil + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + contentType = try ContentType(from: values.decode(String.self, forKey: .contentType)) + if contentType == .applicationJson { + let codableContent = try values.decode([String: AnyCodable].self, forKey: .content) + content = AnyCodable.toAnyDictionary(dictionary: codableContent) ?? [:] + } else { + content = try values.decode(String.self, forKey: .content) + } + publishedDate = try? values.decode(Int.self, forKey: .publishedDate) + expiryDate = try? values.decode(Int.self, forKey: .expiryDate) + let codableMeta = try? values.decode([String: AnyCodable].self, forKey: .meta) + meta = AnyCodable.toAnyDictionary(dictionary: codableMeta) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(contentType.toString(), forKey: .contentType) + if contentType == .applicationJson { + try container.encode(AnyCodable.from(dictionary: content as? [String: Any]), forKey: .content) + } else { + try container.encode(content as? String, forKey: .content) + } + try container.encode(publishedDate, forKey: .publishedDate) + try container.encode(expiryDate, forKey: .expiryDate) + try container.encode(AnyCodable.from(dictionary: meta), forKey: .meta) + } +} + +extension FeedItemSchemaData { + /// ONLY USED FOR TESTING + static func getEmpty() -> FeedItemSchemaData { + FeedItemSchemaData() + } + + public func getFeedItem() -> FeedItem? { + guard contentType == .applicationJson, + let contentAsJsonData = try? JSONSerialization.data(withJSONObject: content, options: .prettyPrinted) + else { + return nil + } + + return try? JSONDecoder().decode(FeedItem.self, from: contentAsJsonData) + } +} diff --git a/AEPMessaging/Sources/schemas/HtmlContentSchemaData.swift b/AEPMessaging/Sources/schemas/HtmlContentSchemaData.swift new file mode 100644 index 00000000..6369b6db --- /dev/null +++ b/AEPMessaging/Sources/schemas/HtmlContentSchemaData.swift @@ -0,0 +1,44 @@ +/* + Copyright 2023 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 Foundation + +/// represents the schema data object for an html content schema +@objc(AEPHtmlContentSchemaData) +@objcMembers +public class HtmlContentSchemaData: NSObject, Codable { + public let content: String + public let format: ContentType? + + enum CodingKeys: String, CodingKey { + case content + case format + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + if let decodedFormat = try? values.decode(String.self, forKey: .format) { + format = ContentType(from: decodedFormat) + } else { + format = .textHtml + } + content = try values.decode(String.self, forKey: .content) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(format?.toString() ?? ContentType.textHtml.toString(), forKey: .format) + try container.encode(content, forKey: .content) + } +} diff --git a/AEPMessaging/Sources/schemas/InAppSchemaData.swift b/AEPMessaging/Sources/schemas/InAppSchemaData.swift new file mode 100644 index 00000000..02a7d0c4 --- /dev/null +++ b/AEPMessaging/Sources/schemas/InAppSchemaData.swift @@ -0,0 +1,144 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +/// represents the schema data object for an in-app schema +@objc(AEPInAppSchemaData) +@objcMembers +public class InAppSchemaData: NSObject, Codable { + public let content: Any + public let contentType: ContentType + public let publishedDate: Int? + public let expiryDate: Int? + public let meta: [String: Any]? + public let mobileParameters: [String: Any]? + public let webParameters: [String: Any]? + public let remoteAssets: [String]? + + enum CodingKeys: String, CodingKey { + case content + case contentType + case publishedDate + case expiryDate + case meta + case mobileParameters + case webParameters + case remoteAssets + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + contentType = try ContentType(from: values.decode(String.self, forKey: .contentType)) + + if contentType == .applicationJson { + if let _ = try? values.decode([AnyCodable].self, forKey: .content) { + let codableAny = try values.decode(AnyCodable.self, forKey: .content) + content = codableAny.arrayValue ?? [] + } else { + let codableDictionary = try values.decode([String: AnyCodable].self, forKey: .content) + content = AnyCodable.toAnyDictionary(dictionary: codableDictionary) ?? [:] + } + } else { + content = try values.decode(String.self, forKey: .content) + } + + publishedDate = try? values.decode(Int.self, forKey: .publishedDate) + expiryDate = try? values.decode(Int.self, forKey: .expiryDate) + let codableMeta = try? values.decode([String: AnyCodable].self, forKey: .meta) + meta = AnyCodable.toAnyDictionary(dictionary: codableMeta) + let codableMobileParams = try? values.decode([String: AnyCodable].self, forKey: .mobileParameters) + mobileParameters = AnyCodable.toAnyDictionary(dictionary: codableMobileParams) + let codableWebParams = try? values.decode([String: AnyCodable].self, forKey: .webParameters) + webParameters = AnyCodable.toAnyDictionary(dictionary: codableWebParams) + remoteAssets = try? values.decode([String].self, forKey: .remoteAssets) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(contentType.toString(), forKey: .contentType) + if contentType == .applicationJson { + if let arrayValue = getArrayValue { + try container.encode(AnyCodable(arrayValue), forKey: .content) + } else if let dictionaryValue = getDictionaryValue { + try container.encode(AnyCodable.from(dictionary: dictionaryValue), forKey: .content) + } + } else { + try container.encode(content as? String, forKey: .content) + } + try container.encode(publishedDate, forKey: .publishedDate) + try container.encode(expiryDate, forKey: .expiryDate) + try container.encode(AnyCodable.from(dictionary: meta), forKey: .meta) + try container.encode(AnyCodable.from(dictionary: mobileParameters), forKey: .mobileParameters) + try container.encode(AnyCodable.from(dictionary: webParameters), forKey: .webParameters) + try container.encode(remoteAssets, forKey: .remoteAssets) + } +} + +extension InAppSchemaData { + var getArrayValue: [Any]? { + content as? [Any] + } + + var getDictionaryValue: [String: Any]? { + content as? [String: Any] + } +} + +extension InAppSchemaData { + func getMessageSettings(with parent: Any?) -> MessageSettings { + guard let mobileParameters = mobileParameters else { + return MessageSettings(parent: parent) + } + + let vAlign = mobileParameters[MessagingConstants.Event.Data.Key.IAM.VERTICAL_ALIGN] as? String + let hAlign = mobileParameters[MessagingConstants.Event.Data.Key.IAM.HORIZONTAL_ALIGN] as? String + var opacity: CGFloat? + if let opacityDouble = mobileParameters[MessagingConstants.Event.Data.Key.IAM.BACKDROP_OPACITY] as? Double { + opacity = CGFloat(opacityDouble) + } + var cornerRadius: CGFloat? + if let cornerRadiusInt = mobileParameters[MessagingConstants.Event.Data.Key.IAM.CORNER_RADIUS] as? Int { + cornerRadius = CGFloat(cornerRadiusInt) + } + let displayAnimation = mobileParameters[MessagingConstants.Event.Data.Key.IAM.DISPLAY_ANIMATION] as? String + let dismissAnimation = mobileParameters[MessagingConstants.Event.Data.Key.IAM.DISMISS_ANIMATION] as? String + var gestures: [MessageGesture: URL]? + if let gesturesMap = mobileParameters[MessagingConstants.Event.Data.Key.IAM.GESTURES] as? [String: String] { + gestures = [:] + for gesture in gesturesMap { + if let gestureEnum = MessageGesture.fromString(gesture.key), let url = URL(string: gesture.value) { + gestures?[gestureEnum] = url + } + } + } + + let settings = MessageSettings(parent: parent) + .setWidth(mobileParameters[MessagingConstants.Event.Data.Key.IAM.WIDTH] as? Int) + .setHeight(mobileParameters[MessagingConstants.Event.Data.Key.IAM.HEIGHT] as? Int) + .setVerticalAlign(MessageAlignment.fromString(vAlign ?? "center")) + .setVerticalInset(mobileParameters[MessagingConstants.Event.Data.Key.IAM.VERTICAL_INSET] as? Int) + .setHorizontalAlign(MessageAlignment.fromString(hAlign ?? "center")) + .setHorizontalInset(mobileParameters[MessagingConstants.Event.Data.Key.IAM.HORIZONTAL_INSET] as? Int) + .setUiTakeover(mobileParameters[MessagingConstants.Event.Data.Key.IAM.UI_TAKEOVER] as? Bool ?? true) + .setBackdropColor(mobileParameters[MessagingConstants.Event.Data.Key.IAM.BACKDROP_COLOR] as? String) + .setBackdropOpacity(opacity) + .setCornerRadius(cornerRadius) + .setDisplayAnimation(MessageAnimation.fromString(displayAnimation ?? "none")) + .setDismissAnimation(MessageAnimation.fromString(dismissAnimation ?? "none")) + .setGestures(gestures) + return settings + } +} diff --git a/AEPMessaging/Sources/schemas/JsonContentSchemaData.swift b/AEPMessaging/Sources/schemas/JsonContentSchemaData.swift new file mode 100644 index 00000000..b1c0115a --- /dev/null +++ b/AEPMessaging/Sources/schemas/JsonContentSchemaData.swift @@ -0,0 +1,69 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +/// represents the schema data object for a json content schema +@objc(AEPJsonContentSchemaData) +@objcMembers +public class JsonContentSchemaData: NSObject, Codable { + public let content: Any + public let format: ContentType? + + enum CodingKeys: String, CodingKey { + case content + case format + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + if let decodedFormat = try? values.decode(String.self, forKey: .format) { + format = ContentType(from: decodedFormat) + } else { + format = .applicationJson + } + + // TODO: core team is adding support for converting [AnyCodable] to [Any] + // we'll need to update this condition to be less awkward when that's released + if let _ = try? values.decode([AnyCodable].self, forKey: .content) { + let codableAny = try values.decode(AnyCodable.self, forKey: .content) + content = codableAny.arrayValue ?? [] + } else { + let codableDictionary = try values.decode([String: AnyCodable].self, forKey: .content) + content = AnyCodable.toAnyDictionary(dictionary: codableDictionary) ?? [:] + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(format?.toString() ?? ContentType.applicationJson.toString(), forKey: .format) + + if let arrayValue = getArrayValue { + try container.encode(AnyCodable(arrayValue), forKey: .content) + } else if let dictionaryValue = getDictionaryValue { + try container.encode(AnyCodable.from(dictionary: dictionaryValue), forKey: .content) + } + } +} + +public extension JsonContentSchemaData { + var getArrayValue: [Any]? { + content as? [Any] + } + + var getDictionaryValue: [String: Any]? { + content as? [String: Any] + } +} diff --git a/AEPMessaging/Sources/schemas/RulesetSchemaData.swift b/AEPMessaging/Sources/schemas/RulesetSchemaData.swift new file mode 100644 index 00000000..ed8df173 --- /dev/null +++ b/AEPMessaging/Sources/schemas/RulesetSchemaData.swift @@ -0,0 +1,42 @@ +/* + Copyright 2023 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 AEPServices +import Foundation + +/// represents the schema data object for a ruleset schema +@objc(AEPRulesetSchemaData) +@objcMembers +public class RulesetSchemaData: NSObject, Codable { + public let version: Int + public let rules: [[String: Any]] + + enum CodingKeys: String, CodingKey { + case version + case rules + } + + public required init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + version = try values.decode(Int.self, forKey: .version) + let codableRulesArray = try values.decode([[String: AnyCodable]].self, forKey: .rules) + rules = codableRulesArray.compactMap { AnyCodable.toAnyDictionary(dictionary: $0) } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(version, forKey: .version) + try container.encode(rules.compactMap { AnyCodable.from(dictionary: $0) }, forKey: .rules) + } +} diff --git a/AEPMessaging/Sources/schemas/SchemaType.swift b/AEPMessaging/Sources/schemas/SchemaType.swift new file mode 100644 index 00000000..75b9b64e --- /dev/null +++ b/AEPMessaging/Sources/schemas/SchemaType.swift @@ -0,0 +1,79 @@ +/* + Copyright 2023 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 Foundation + +/// Enum representing schema types. +@objc(AEPSchemaType) +public enum SchemaType: Int, Codable { + case unknown = 0 + case htmlContent = 1 + case jsonContent = 2 + case ruleset = 3 + case inapp = 4 + case feed = 5 + case nativeAlert = 6 + case defaultContent = 7 + + /// Initializes SchemaType with the provided content schema string + /// - Parameter schema: SchemaType content schema string + init(from schema: String) { + switch schema { + case MessagingConstants.PersonalizationSchemas.HTML_CONTENT: + self = .htmlContent + + case MessagingConstants.PersonalizationSchemas.JSON_CONTENT: + self = .jsonContent + + case MessagingConstants.PersonalizationSchemas.RULESET_ITEM: + self = .ruleset + + case MessagingConstants.PersonalizationSchemas.IN_APP: + self = .inapp + + case MessagingConstants.PersonalizationSchemas.FEED_ITEM: + self = .feed + + case MessagingConstants.PersonalizationSchemas.NATIVE_ALERT: + self = .nativeAlert + + case MessagingConstants.PersonalizationSchemas.DEFAULT_CONTENT: + self = .defaultContent + + default: + self = .unknown + } + } + + /// Returns the schema type string. + /// - Returns: A string representing the schema type. + public func toString() -> String { + switch self { + case .htmlContent: + return MessagingConstants.PersonalizationSchemas.HTML_CONTENT + case .jsonContent: + return MessagingConstants.PersonalizationSchemas.JSON_CONTENT + case .ruleset: + return MessagingConstants.PersonalizationSchemas.RULESET_ITEM + case .inapp: + return MessagingConstants.PersonalizationSchemas.IN_APP + case .feed: + return MessagingConstants.PersonalizationSchemas.FEED_ITEM + case .nativeAlert: + return MessagingConstants.PersonalizationSchemas.NATIVE_ALERT + case .defaultContent: + return MessagingConstants.PersonalizationSchemas.DEFAULT_CONTENT + default: + return "" + } + } +} diff --git a/AEPMessaging/Tests/E2EFunctionalTests/E2EFunctionalTests.swift b/AEPMessaging/Tests/E2EFunctionalTests/E2EFunctionalTests.swift index b2b6cdb7..aa9f00fa 100644 --- a/AEPMessaging/Tests/E2EFunctionalTests/E2EFunctionalTests.swift +++ b/AEPMessaging/Tests/E2EFunctionalTests/E2EFunctionalTests.swift @@ -17,15 +17,18 @@ import AEPEdgeIdentity @testable import AEPMessaging @testable import AEPRulesEngine @testable import AEPServices +import AEPTestUtils import XCTest -class E2EFunctionalTests: XCTestCase { +class E2EFunctionalTests: XCTestCase, AnyCodableAsserts { // testing variables var currentMessage: Message? let asyncTimeout: TimeInterval = 30 let appScope = "mobileapp://com.adobe.ajoinbounde2etestsonly" + var mockCache: MockCache! + var mockRuntime: TestableExtensionRuntime! override class func setUp() { // before all @@ -38,6 +41,8 @@ class E2EFunctionalTests: XCTestCase { override func setUp() { // before each + mockCache = MockCache(name: "mockCache") + mockRuntime = TestableExtensionRuntime() } override func tearDown() { @@ -70,7 +75,7 @@ class E2EFunctionalTests: XCTestCase { } func registerMessagingRequestContentListener(_ listener: @escaping EventListener) { - MobileCore.registerEventListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, listener: listener) + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent, listener: listener) } func registerEdgePersonalizationDecisionsListener(_ listener: @escaping EventListener) { @@ -121,7 +126,7 @@ class E2EFunctionalTests: XCTestCase { // loop through the payload and verify the format for each object for payloadObject in payload { - XCTAssertTrue(self.payloadObjectIsValid(payloadObject), "SDK TEST ERROR - payload object returned was invalid: \(payloadObject)") + self.validatePayloadObject(payloadObject) } edgePersonalizationDecisionsExpectation.fulfill() @@ -134,33 +139,47 @@ class E2EFunctionalTests: XCTestCase { wait(for: [edgePersonalizationDecisionsExpectation], timeout: asyncTimeout) } - func testMessagesReturnedFromXASHaveCorrectRuleFormat() throws { - // setup - let edgePersonalizationDecisionsExpectation = XCTestExpectation(description: "edge personalization decisions listener called") - registerEdgePersonalizationDecisionsListener() { event in - - // validate the content is a valid rule containing a valid message - guard let propositions = event.payload else { - // no payload means this event is a request, not a response - return - } - - let messagingRulesEngine = MessagingRulesEngine(name: "testRulesEngine", extensionRuntime: TestableExtensionRuntime()) - messagingRulesEngine.loadPropositions(propositions, clearExisting: true, expectedScope: self.appScope) - - // rules load async - brief sleep to allow it to finish - self.runAfter(seconds: 3) { - XCTAssertTrue(messagingRulesEngine.rulesEngine.rulesEngine.rules.count > 0, "Message definition successfully loaded into the rules engine.") - edgePersonalizationDecisionsExpectation.fulfill() - } - } - - // test - Messaging.refreshInAppMessages() - - // verify - wait(for: [edgePersonalizationDecisionsExpectation], timeout: asyncTimeout) - } + // TODO: - update these tests with v2 format + +// func testMessagesReturnedFromXASHaveCorrectRuleFormat() throws { +// // setup +// let edgePersonalizationDecisionsExpectation = XCTestExpectation(description: "edge personalization decisions listener called") +// registerEdgePersonalizationDecisionsListener() { event in +// +// // validate the content is a valid rule containing a valid message +// guard let propositions = event.payload else { +// // no payload means this event is a request, not a response +// return +// } +// +// let messagingRulesEngine = MessagingRulesEngine(name: "testRulesEngine", extensionRuntime: self.mockRuntime, cache: self.mockCache) +// var rulesArray: [LaunchRule] = [] +// +// // loop though the payload and parse the rule +// for proposition in propositions { +// if let ruleString = proposition.items.first?.data.content, +// !ruleString.isEmpty, +// let rule = messagingRulesEngine.parseRule(ruleString) { +// rulesArray.append(contentsOf: rule) +// } +// } +// +// // load the parsed rules into the rules engine +// messagingRulesEngine.loadRules(rulesArray, clearExisting: true) +// +// // rules load async - brief sleep to allow it to finish +// self.runAfter(seconds: 3) { +// XCTAssertTrue(messagingRulesEngine.rulesEngine.rulesEngine.rules.count > 0, "Message definition successfully loaded into the rules engine.") +// edgePersonalizationDecisionsExpectation.fulfill() +// } +// } +// +// // test +// Messaging.refreshInAppMessages() +// +// // verify +// wait(for: [edgePersonalizationDecisionsExpectation], timeout: asyncTimeout) +// } // func testMessagesDisplayInteractDismissEvents() throws { // // setup @@ -196,209 +215,36 @@ class E2EFunctionalTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds), execute: closure) } - func payloadObjectIsValid(_ payload: [String: Any]) -> Bool { - - var objectIsValid = true - - /// { - /// "id": "string", - /// "scope": "string", - /// "scopeDetails": { - /// "activity": { - /// "id": "string" - /// }, - /// "characteristics": { - /// "eventToken": "string" - /// }, - /// "correlationID": "string", - /// "decisionProvider": "string" - /// }, - /// "items": [ - /// { - /// "id": "string", - /// "schema": "string", - /// "data": { - /// "content": "string", // <<< sdk rule - /// "id": "string" - /// } - /// } - /// ] - /// } - - // validate required fields are in first payload item and their types are correct - if !payload.contains(where: { $0.key == "id" }) { - XCTFail(self.missingField("id")) - objectIsValid = false - } else { - let value = payload["id"] as? String - if value == nil { - XCTFail(self.wrongType("id", expected: "String")) - objectIsValid = false - } - } - - if !payload.contains(where: { $0.key == "scope" }) { - XCTFail(self.missingField("scope")) - objectIsValid = false - } else { - let value = payload["scope"] as? String - if value == nil { - XCTFail(self.wrongType("scope", expected: "String")) - objectIsValid = false - } + func validatePayloadObject(_ payload: [String: Any]) { + let expectedPayloadJSON = #""" + { + "id": "string", + "scope": "string", + "scopeDetails": { + "activity": { + "id": "string" + }, + "characteristics": { + "eventToken": "string" + }, + "correlationID": "string", + "decisionProvider": "string" + }, + "items": [ + { + "id": "string", + "schema": "string", + "data": { + "content": "string", + "id": "string" + } + } + ] } - - var scopeDetails: [String: Any]? - if !payload.contains(where: { $0.key == "scopeDetails" }) { - XCTFail(self.missingField("scopeDetails")) - objectIsValid = false - } else { - scopeDetails = payload["scopeDetails"] as? [String: Any] - if scopeDetails == nil { - XCTFail(self.wrongType("scopeDetails", expected: "[String: Any]")) - objectIsValid = false - } - } - - var activity: [String: Any]? - if !(scopeDetails?.contains(where: { $0.key == "activity" }) ?? false) { - XCTFail(self.missingField("scopeDetails.activity")) - objectIsValid = false - } else { - activity = scopeDetails?["activity"] as? [String: Any] - if activity == nil { - XCTFail(self.wrongType("scopeDetails.activity", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(activity?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("scopeDetails.activity.id")) - objectIsValid = false - } else { - let value = activity?["id"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.activity.id", expected: "String")) - objectIsValid = false - } - } - - var characteristics: [String: Any]? - if !(scopeDetails?.contains(where: { $0.key == "characteristics" }) ?? false) { - XCTFail(self.missingField("scopeDetails.characteristics")) - objectIsValid = false - } else { - characteristics = scopeDetails?["characteristics"] as? [String: Any] - if characteristics == nil { - XCTFail(self.wrongType("scopeDetails.characteristics", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(characteristics?.contains(where: { $0.key == "eventToken" }) ?? false) { - XCTFail(self.missingField("scopeDetails.characteristics.eventToken")) - objectIsValid = false - } else { - let value = characteristics?["eventToken"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.characteristics.eventToken", expected: "String")) - objectIsValid = false - } - } - - if !(scopeDetails?.contains(where: { $0.key == "correlationID" }) ?? false) { - XCTFail(self.missingField("scopeDetails.correlationID")) - objectIsValid = false - } else { - let value = scopeDetails?["correlationID"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.correlationID", expected: "String")) - objectIsValid = false - } - } - - if !(scopeDetails?.contains(where: { $0.key == "decisionProvider" }) ?? false) { - XCTFail(self.missingField("scopeDetails.decisionProvider")) - objectIsValid = false - } else { - let value = scopeDetails?["decisionProvider"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.decisionProvider", expected: "String")) - objectIsValid = false - } - } - - var items: [[String: Any]]? - if !payload.contains(where: { $0.key == "items" }) { - XCTFail(self.missingField("items")) - objectIsValid = false - } else { - items = payload["items"] as? [[String: Any]] - if items == nil { - XCTFail(self.wrongType("items", expected: "[[String: Any]]")) - objectIsValid = false - } - } - - let item = items?.first - - if !(item?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("items[0].id")) - objectIsValid = false - } else { - let value = item?["id"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].id", expected: "String")) - objectIsValid = false - } - } - - if !(item?.contains(where: { $0.key == "schema" }) ?? false) { - XCTFail(self.missingField("items[0].schema")) - objectIsValid = false - } else { - let value = item?["schema"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].schema", expected: "String")) - objectIsValid = false - } - } - - var itemData: [String: Any]? - if !(item?.contains(where: { $0.key == "data" }) ?? false) { - XCTFail(self.missingField("items[0].data")) - objectIsValid = false - } else { - itemData = item?["data"] as? [String: Any] - if itemData == nil { - XCTFail(self.wrongType("items[0].data", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(itemData?.contains(where: { $0.key == "content" }) ?? false) { - XCTFail(self.missingField("items[0].data.content")) - objectIsValid = false - } else { - let value = itemData?["content"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].data.content", expected: "String")) - objectIsValid = false - } - } - - if !(itemData?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("items[0].data.id")) - objectIsValid = false - } else { - let value = itemData?["id"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].data.id", expected: "String")) - objectIsValid = false - } - } - - return objectIsValid + """# + + // validate required fields are in first payload item and their types are correct + assertTypeMatch(expected: expectedPayloadJSON.toAnyCodable()!, actual: AnyCodable(payload), pathOptions: []) } func missingField(_ key: String) -> String { @@ -412,7 +258,7 @@ class E2EFunctionalTests: XCTestCase { extension E2EFunctionalTests: MessagingDelegate { func onShow(message: Showable) { - currentMessage?.track("clicked", withEdgeEventType: .inappInteract) + currentMessage?.track("clicked", withEdgeEventType: .interact) } func onDismiss(message: Showable) { diff --git a/AEPMessaging/Tests/FunctionalTests/InAppMessagingEventTests.swift b/AEPMessaging/Tests/FunctionalTests/InAppMessagingEventTests.swift index dd015ff0..c7a4e71f 100644 --- a/AEPMessaging/Tests/FunctionalTests/InAppMessagingEventTests.swift +++ b/AEPMessaging/Tests/FunctionalTests/InAppMessagingEventTests.swift @@ -17,15 +17,18 @@ import AEPEdgeIdentity @testable import AEPMessaging @testable import AEPRulesEngine @testable import AEPServices +import AEPTestUtils import XCTest -class InAppMessagingEventTests: XCTestCase { +class InAppMessagingEventTests: XCTestCase, AnyCodableAsserts { // testing variables var currentMessage: Message? let asyncTimeout: TimeInterval = 30 let expectedScope = "mobileapp://com.adobe.ajo.e2eTestApp" + var mockCache: MockCache! + var mockRuntime: TestableExtensionRuntime! override class func setUp() { // before all @@ -39,6 +42,8 @@ class InAppMessagingEventTests: XCTestCase { override func setUp() { // before each + mockCache = MockCache(name: "mockCache") + mockRuntime = TestableExtensionRuntime() } override func tearDown() { @@ -70,7 +75,7 @@ class InAppMessagingEventTests: XCTestCase { } func registerMessagingRequestContentListener(_ listener: @escaping EventListener) { - MobileCore.registerEventListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, listener: listener) + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent, listener: listener) } func registerEdgePersonalizationDecisionsListener(_ listener: @escaping EventListener) { @@ -133,7 +138,7 @@ class InAppMessagingEventTests: XCTestCase { // // verify // wait(for: [edgePersonalizationDecisionsExpectation], timeout: asyncTimeout) // } -// +// // func testMessagesReturnedFromXASHaveCorrectRuleFormat() throws { // // setup // let edgePersonalizationDecisionsExpectation = XCTestExpectation(description: "edge personalization decisions listener called") @@ -196,209 +201,35 @@ class InAppMessagingEventTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(seconds), execute: closure) } - func payloadObjectIsValid(_ payload: [String: Any]) -> Bool { - - var objectIsValid = true - - /// { - /// "id": "string", - /// "scope": "string", - /// "scopeDetails": { - /// "activity": { - /// "id": "string" - /// }, - /// "characteristics": { - /// "eventToken": "string" - /// }, - /// "correlationID": "string", - /// "decisionProvider": "string" - /// }, - /// "items": [ - /// { - /// "id": "string", - /// "schema": "string", - /// "data": { - /// "content": "string", // <<< sdk rule - /// "id": "string" - /// } - /// } - /// ] - /// } - - // validate required fields are in first payload item and their types are correct - if !payload.contains(where: { $0.key == "id" }) { - XCTFail(self.missingField("id")) - objectIsValid = false - } else { - let value = payload["id"] as? String - if value == nil { - XCTFail(self.wrongType("id", expected: "String")) - objectIsValid = false - } - } - - if !payload.contains(where: { $0.key == "scope" }) { - XCTFail(self.missingField("scope")) - objectIsValid = false - } else { - let value = payload["scope"] as? String - if value == nil { - XCTFail(self.wrongType("scope", expected: "String")) - objectIsValid = false - } - } - - var scopeDetails: [String: Any]? - if !payload.contains(where: { $0.key == "scopeDetails" }) { - XCTFail(self.missingField("scopeDetails")) - objectIsValid = false - } else { - scopeDetails = payload["scopeDetails"] as? [String: Any] - if scopeDetails == nil { - XCTFail(self.wrongType("scopeDetails", expected: "[String: Any]")) - objectIsValid = false - } - } - - var activity: [String: Any]? - if !(scopeDetails?.contains(where: { $0.key == "activity" }) ?? false) { - XCTFail(self.missingField("scopeDetails.activity")) - objectIsValid = false - } else { - activity = scopeDetails?["activity"] as? [String: Any] - if activity == nil { - XCTFail(self.wrongType("scopeDetails.activity", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(activity?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("scopeDetails.activity.id")) - objectIsValid = false - } else { - let value = activity?["id"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.activity.id", expected: "String")) - objectIsValid = false - } - } - - var characteristics: [String: Any]? - if !(scopeDetails?.contains(where: { $0.key == "characteristics" }) ?? false) { - XCTFail(self.missingField("scopeDetails.characteristics")) - objectIsValid = false - } else { - characteristics = scopeDetails?["characteristics"] as? [String: Any] - if characteristics == nil { - XCTFail(self.wrongType("scopeDetails.characteristics", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(characteristics?.contains(where: { $0.key == "eventToken" }) ?? false) { - XCTFail(self.missingField("scopeDetails.characteristics.eventToken")) - objectIsValid = false - } else { - let value = characteristics?["eventToken"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.characteristics.eventToken", expected: "String")) - objectIsValid = false - } - } - - if !(scopeDetails?.contains(where: { $0.key == "correlationID" }) ?? false) { - XCTFail(self.missingField("scopeDetails.correlationID")) - objectIsValid = false - } else { - let value = scopeDetails?["correlationID"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.correlationID", expected: "String")) - objectIsValid = false - } - } - - if !(scopeDetails?.contains(where: { $0.key == "decisionProvider" }) ?? false) { - XCTFail(self.missingField("scopeDetails.decisionProvider")) - objectIsValid = false - } else { - let value = scopeDetails?["decisionProvider"] as? String - if value == nil { - XCTFail(self.wrongType("scopeDetails.decisionProvider", expected: "String")) - objectIsValid = false - } - } - - var items: [[String: Any]]? - if !payload.contains(where: { $0.key == "items" }) { - XCTFail(self.missingField("items")) - objectIsValid = false - } else { - items = payload["items"] as? [[String: Any]] - if items == nil { - XCTFail(self.wrongType("items", expected: "[[String: Any]]")) - objectIsValid = false - } - } - - let item = items?.first - - if !(item?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("items[0].id")) - objectIsValid = false - } else { - let value = item?["id"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].id", expected: "String")) - objectIsValid = false - } - } - - if !(item?.contains(where: { $0.key == "schema" }) ?? false) { - XCTFail(self.missingField("items[0].schema")) - objectIsValid = false - } else { - let value = item?["schema"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].schema", expected: "String")) - objectIsValid = false - } - } - - var itemData: [String: Any]? - if !(item?.contains(where: { $0.key == "data" }) ?? false) { - XCTFail(self.missingField("items[0].data")) - objectIsValid = false - } else { - itemData = item?["data"] as? [String: Any] - if itemData == nil { - XCTFail(self.wrongType("items[0].data", expected: "[String: Any]")) - objectIsValid = false - } - } - - if !(itemData?.contains(where: { $0.key == "content" }) ?? false) { - XCTFail(self.missingField("items[0].data.content")) - objectIsValid = false - } else { - let value = itemData?["content"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].data.content", expected: "String")) - objectIsValid = false - } - } - - if !(itemData?.contains(where: { $0.key == "id" }) ?? false) { - XCTFail(self.missingField("items[0].data.id")) - objectIsValid = false - } else { - let value = itemData?["id"] as? String - if value == nil { - XCTFail(self.wrongType("items[0].data.id", expected: "String")) - objectIsValid = false + func validatePayloadObject(_ payload: [String: Any]) { + let expectedJSON = #""" + { + "id": "string", + "items": [ + { + "data": { + "content": "string", + "id": "string" + }, + "id": "string", + "schema": "string" } + ], + "scope": "string", + "scopeDetails": { + "activity": { + "id": "string" + }, + "characteristics": { + "eventToken": "string" + }, + "correlationID": "string", + "decisionProvider": "string" + } } + """# - return objectIsValid + assertTypeMatch(expected: expectedJSON.toAnyCodable()!, actual: AnyCodable.from(dictionary: (payload)), pathOptions: []) } func missingField(_ key: String) -> String { @@ -412,7 +243,7 @@ class InAppMessagingEventTests: XCTestCase { extension InAppMessagingEventTests: MessagingDelegate { func onShow(message: Showable) { - currentMessage?.track("clicked", withEdgeEventType: .inappInteract) + currentMessage?.track("clicked", withEdgeEventType: .interact) } func onDismiss(message: Showable) { diff --git a/AEPMessaging/Tests/FunctionalTests/MessagingFunctionalTests.swift b/AEPMessaging/Tests/FunctionalTests/MessagingFunctionalTests.swift index e660dd2c..5d0145ed 100644 --- a/AEPMessaging/Tests/FunctionalTests/MessagingFunctionalTests.swift +++ b/AEPMessaging/Tests/FunctionalTests/MessagingFunctionalTests.swift @@ -13,9 +13,10 @@ @testable import AEPCore @testable import AEPMessaging @testable import AEPServices +import AEPTestUtils import XCTest -class MessagingFunctionalTests: XCTestCase { +class MessagingFunctionalTests: XCTestCase, AnyCodableAsserts { var messaging: Messaging! var mockRuntime: TestableExtensionRuntime! var mockConfigSharedState: [String: Any] = [:] @@ -42,19 +43,39 @@ class MessagingFunctionalTests: XCTestCase { mockRuntime.simulateComingEvents(event) XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let edgeEvent = mockRuntime.firstEvent - - XCTAssertEqual(edgeEvent?.type, EventType.edge) - let flattenEdgeEvent = edgeEvent?.data?.flattening() - let pushNotification = flattenEdgeEvent?["data.pushNotificationDetails"] as? [[String: Any]] - XCTAssertEqual(1, pushNotification?.count) - let flattenedPushNotification = pushNotification?.first?.flattening() - XCTAssertEqual("MOCK_ECID", flattenedPushNotification?["identity.id"] as? String) - XCTAssertEqual("mockPushToken", flattenedPushNotification?["token"] as? String) - XCTAssertEqual("com.adobe.ajo.e2eTestApp", flattenedPushNotification?["appID"] as? String) - XCTAssertEqual(false, flattenedPushNotification?["denylisted"] as? Bool) - XCTAssertEqual("ECID", flattenedPushNotification?["identity.namespace.code"] as? String) - XCTAssertEqual("apns", flattenedPushNotification?["platform"] as? String) + guard let edgeEvent = mockRuntime.firstEvent else { + XCTFail("Unable to find Edge event") + return + } + + XCTAssertEqual(edgeEvent.type, EventType.edge) + + let expectedJSON = #""" + { + "data": { + "pushNotificationDetails": [ + { + "identity": { + "id": "MOCK_ECID", + "namespace": { + "code": "ECID" + } + }, + "token": "mockPushToken", + "appID": "com.adobe.ajo.e2eTestApp", + "denylisted": false, + "platform": "apns" + } + ] + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) + if let dataDict = edgeEvent.data?["data"] as? [String: Any], + let pushNotificationDetails = dataDict["pushNotificationDetails"] as? [[String: Any]] { + XCTAssertEqual(1, pushNotificationDetails.count) + } // verify that push token is shared in sharedState XCTAssertEqual(1, mockRuntime.createdSharedStates.count) @@ -131,18 +152,35 @@ class MessagingFunctionalTests: XCTestCase { mockRuntime.simulateComingEvents(event) XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) let edgeEvent = mockRuntime.dispatchedEvents[0] - + XCTAssertEqual(edgeEvent.type, EventType.edge) - let flattenEdgeEvent = edgeEvent.data?.flattening() - let pushNotification = flattenEdgeEvent?["data.pushNotificationDetails"] as? [[String: Any]] - XCTAssertEqual(1, pushNotification?.count) - let flattenedPushNotification = pushNotification?.first?.flattening() - XCTAssertEqual("MOCK_ECID", flattenedPushNotification?["identity.id"] as? String) - XCTAssertEqual("mockPushToken", flattenedPushNotification?["token"] as? String) - XCTAssertEqual("com.adobe.ajo.e2eTestApp", flattenedPushNotification?["appID"] as? String) - XCTAssertEqual(false, flattenedPushNotification?["denylisted"] as? Bool) - XCTAssertEqual("ECID", flattenedPushNotification?["identity.namespace.code"] as? String) - XCTAssertEqual("apnsSandbox", flattenedPushNotification?["platform"] as? String) + + let expectedJSON = #""" + { + "data": { + "pushNotificationDetails": [ + { + "identity": { + "id": "MOCK_ECID", + "namespace": { + "code": "ECID" + } + }, + "token": "mockPushToken", + "appID": "com.adobe.ajo.e2eTestApp", + "denylisted": false, + "platform": "apnsSandbox" + } + ] + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) + if let dataDict = edgeEvent.data?["data"] as? [String: Any], + let pushNotificationDetails = dataDict["pushNotificationDetails"] as? [[String: Any]] { + XCTAssertEqual(1, pushNotificationDetails.count) + } // verify that push token is shared in sharedState XCTAssertEqual(1, mockRuntime.createdSharedStates.count) diff --git a/AEPMessaging/Tests/FunctionalTests/MessagingNotificationTrackingTests.swift b/AEPMessaging/Tests/FunctionalTests/MessagingNotificationTrackingTests.swift index 65ebfeb9..4d42da9b 100644 --- a/AEPMessaging/Tests/FunctionalTests/MessagingNotificationTrackingTests.swift +++ b/AEPMessaging/Tests/FunctionalTests/MessagingNotificationTrackingTests.swift @@ -14,10 +14,13 @@ import Foundation @testable import AEPCore @testable import AEPMessaging import AEPEdgeIdentity +import AEPServices +import AEPTestUtils import XCTest -class MessagingNotificationTrackingTests: FunctionalTestBase { - +class MessagingNotificationTrackingTests: TestBase, AnyCodableAsserts { + private let mockNetworkService: MockNetworkService = MockNetworkService() + static let mockUserInfo = ["_xdm" : ["cjm": ["_experience": @@ -36,13 +39,16 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { public class override func setUp() { super.setUp() - FunctionalTestBase.debugEnabled = true + TestBase.debugEnabled = true } override func setUp() { super.setUp() + + ServiceProvider.shared.networkService = mockNetworkService continueAfterFailure = true FileManager.default.clearCache() + FileManager.default.clearDirectory() // hub shared state update for 1 extension versions (InstrumentedExtension (registered in FunctionalTestBase), IdentityEdge, Edge Identity, Config setExpectationEvent(type: EventType.hub, source: EventSource.sharedState, expectedCount: 3) @@ -66,6 +72,11 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { setNotificationCategories() } + override func tearDown() { + super.tearDown() + mockNetworkService.reset() + } + // MARK: - Tests func test_notificationTracking_whenUser_tapsNotificationBody() { @@ -89,25 +100,59 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.edge, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let edgeEvent = events.first! - let flattenEdgeEvent = edgeEvent.data?.flattening() - + + // Note: JSON comparison tool cannot currently validate that a key does not exist + // it also cannot strictly validate the count of collections when using assertExact/TypeMatch modes + if let xdm = edgeEvent.data?["xdm"] as? [String: Any], + let pushNotificationTracking = xdm["pushNotificationTracking"] as? [String: Any], + let customAction = pushNotificationTracking["customAction"] as? [String: Any], + let actionID = customAction["actionID"] as? String { + // use actionID here + XCTAssertNil(actionID) + } // verify push tracking information - XCTAssertEqual(1, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.applicationOpened", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertNil(flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) - // verify cjm/mixins and other xdm related data - XCTAssertEqual("mockJourneyVersionID", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.journeyVersionID"] as? String) - XCTAssertEqual("mockJourneyVersionInstanceId", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.journeyVersionInstanceId"] as? String) - XCTAssertEqual("mockMessageId", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.messageID"] as? String) - XCTAssertEqual("apns", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.pushChannelContext.platform"] as? String) - XCTAssertEqual("https://ns.adobe.com/xdm/channels/push", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageProfile.channel._id"] as? String) - XCTAssertEqual("mockExecutionID", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.messageExecutionID"] as? String) - - XCTAssertEqual("apns", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProvider"] as? String) - XCTAssertEqual("messageId", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProviderMessageID"] as? String) - XCTAssertEqual("mockDataset", flattenEdgeEvent?["meta.collect.datasetId"] as? String) + let expectedJSON = #""" + { + "meta": { + "collect": { + "datasetId": "mockDataset" + } + }, + "xdm": { + "_experience": { + "customerJourneyManagement": { + "messageExecution": { + "journeyVersionID": "mockJourneyVersionID", + "journeyVersionInstanceId": "mockJourneyVersionInstanceId", + "messageExecutionID": "mockExecutionID", + "messageID": "mockMessageId" + }, + "messageProfile": { + "channel": { + "_id": "https://ns.adobe.com/xdm/channels/push" + } + }, + "pushChannelContext": { + "platform": "apns" + } + } + }, + "application": { + "launches": { + "value": 1 + } + }, + "eventType": "pushTracking.applicationOpened", + "pushNotificationTracking": { + "pushProvider": "apns", + "pushProviderMessageID": "messageId" + } + } + } + """# + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func test_notificationTracking_whenUser_DismissesNotification() { @@ -122,12 +167,26 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.edge, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let edgeEvent = events.first! - let flattenEdgeEvent = edgeEvent.data?.flattening() // verify push tracking information - XCTAssertEqual(0, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("Dismiss",flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) + let expectedJSON = #""" + { + "xdm" : { + "pushNotificationTracking" : { + "customAction" : { + "actionID" : "Dismiss" + } + }, + "eventType" : "pushTracking.customAction", + "application" : { + "launches" : { + "value" : 0 + } + } + } + } + """# + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func test_notificationTracking_whenUser_tapsNotificationActionThatOpensTheApp() { @@ -142,12 +201,27 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.edge, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let edgeEvent = events.first! - let flattenEdgeEvent = edgeEvent.data?.flattening() // verify push tracking information - XCTAssertEqual(1, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("ForegroundActionId",flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) + let expectedJSON = #""" + { + "xdm": { + "application": { + "launches": { + "value": 1 + } + }, + "eventType": "pushTracking.customAction", + "pushNotificationTracking": { + "customAction": { + "actionID": "ForegroundActionId" + } + } + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func test_notificationOpen_whenNotAJONotification() { @@ -208,12 +282,27 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.edge, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let edgeEvent = events.first! - let flattenEdgeEvent = edgeEvent.data?.flattening() // verify push tracking information - XCTAssertEqual(0, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("DeclineActionId",flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) + let expectedJSON = #""" + { + "xdm": { + "application": { + "launches": { + "value": 0 + } + }, + "eventType": "pushTracking.customAction", + "pushNotificationTracking": { + "customAction": { + "actionID": "DeclineActionId" + } + } + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func test_notificationTracking_whenUser_tapsNotificationActionThatDoNotOpenTheApp_Case2() { @@ -229,12 +318,27 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.edge, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let edgeEvent = events.first! - let flattenEdgeEvent = edgeEvent.data?.flattening() // verify push tracking information - XCTAssertEqual(0, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("notForegroundActionId",flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) + let expectedJSON = #""" + { + "xdm": { + "application": { + "launches": { + "value": 0 + } + }, + "eventType": "pushTracking.customAction", + "pushNotificationTracking": { + "customAction": { + "actionID": "notForegroundActionId" + } + } + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func test_notificationOpen_willLaunchUrl() { @@ -250,12 +354,17 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { let events = getDispatchedEventsWith(type: EventType.messaging, source: EventSource.requestContent) XCTAssertEqual(1, events.count) let messagingEvent = events.first! - let flattenedEvent = messagingEvent.data?.flattening() // verify push tracking information - XCTAssertTrue(((flattenedEvent?["applicationOpened"] as? Bool) != nil)) - XCTAssertEqual("https://google.com", flattenedEvent?["clickThroughUrl"] as? String) - XCTAssertEqual("pushTracking.applicationOpened", flattenedEvent?["eventType"] as? String) + let expectedJSON = #""" + { + "applicationOpened": true, + "clickThroughUrl": "https://google.com", + "eventType": "pushTracking.applicationOpened" + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: messagingEvent.toAnyCodable(), pathOptions: []) } func test_notificationCustomAction_willNotLaunchUrl() { @@ -303,7 +412,6 @@ class MessagingNotificationTrackingTests: FunctionalTestBase { // verify push tracking information XCTAssertNil(flattenedEvent?["pushClickThroughUrl"] as? String) } - // MARK: - Private Helpers functions diff --git a/AEPMessaging/Tests/FunctionalTests/MessagingPublicAPITests.swift b/AEPMessaging/Tests/FunctionalTests/MessagingPublicAPITests.swift index 6531ada4..41d5cf23 100644 --- a/AEPMessaging/Tests/FunctionalTests/MessagingPublicAPITests.swift +++ b/AEPMessaging/Tests/FunctionalTests/MessagingPublicAPITests.swift @@ -13,9 +13,10 @@ @testable import AEPCore @testable import AEPMessaging @testable import AEPServices +import AEPTestUtils import XCTest -class MessagingPublicAPITests: XCTestCase { +class MessagingPublicAPITests: XCTestCase, AnyCodableAsserts { var messaging: Messaging! var mockRuntime: TestableExtensionRuntime! var mockConfigSharedState: [String: Any] = [:] @@ -31,7 +32,7 @@ class MessagingPublicAPITests: XCTestCase { // MARK: - Handle Notification Response func testHandleNotificationResponse() { - let event = Event(name: "", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getEventData()) + let event = Event(name: "", type: EventType.messaging, source: EventSource.requestContent, data: getEventData()) // mock configuration shared state mockRuntime.simulateSharedState(for: (extensionName: "com.adobe.module.configuration", event: event), data: (value: mockConfigSharedState, status: .set)) @@ -45,24 +46,55 @@ class MessagingPublicAPITests: XCTestCase { let edgeEvent = mockRuntime.dispatchedEvents[1] XCTAssertEqual(edgeEvent.type, EventType.edge) - let flattenEdgeEvent = edgeEvent.data?.flattening() - XCTAssertEqual("apns", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProvider"] as? String) - XCTAssertEqual("mockMessageId", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProviderMessageID"] as? String) - XCTAssertEqual(1, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("mockEventDataset", flattenEdgeEvent?["meta.collect.datasetId"] as? String) - XCTAssertEqual("mockCustomActionId", flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) - // cjm/mixins data - XCTAssertEqual("some-journeyVersionId", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.journeyVersionID"] as? String) - XCTAssertEqual("someJourneyVersionInstanceId", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.journeyVersionInstanceId"] as? String) - XCTAssertEqual("567", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.messageID"] as? String) - XCTAssertEqual("apns", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.pushChannelContext.platform"] as? String) - XCTAssertEqual("https://ns.adobe.com/xdm/channels/push", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageProfile.channel._id"] as? String) - XCTAssertEqual("16-Sept-postman", flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.messageExecutionID"] as? String) + + let expectedJSON = #""" + { + "xdm": { + "pushNotificationTracking": { + "pushProvider": "apns", + "pushProviderMessageID": "mockMessageId", + "customAction": { + "actionID": "mockCustomActionId" + } + }, + "application": { + "launches": { + "value": 1 + } + }, + "eventType": "pushTracking.customAction", + "_experience": { + "customerJourneyManagement": { + "messageExecution": { + "journeyVersionID": "some-journeyVersionId", + "journeyVersionInstanceId": "someJourneyVersionInstanceId", + "messageID": "567", + "messageExecutionID": "16-Sept-postman" + }, + "pushChannelContext": { + "platform": "apns" + }, + "messageProfile": { + "channel": { + "_id": "https://ns.adobe.com/xdm/channels/push" + } + } + } + } + }, + "meta": { + "collect": { + "datasetId": "mockEventDataset" + } + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } func testHandleNotificationResponse_noEventDatasetId() { - let event = Event(name: "", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getEventData()) + let event = Event(name: "", type: EventType.messaging, source: EventSource.requestContent, data: getEventData()) // empty datasetId mockConfigSharedState = [:] @@ -80,7 +112,7 @@ class MessagingPublicAPITests: XCTestCase { } func testHandleNotificationResponse_datasetIdIsEmpty() { - let event = Event(name: "", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getEventData()) + let event = Event(name: "", type: EventType.messaging, source: EventSource.requestContent, data: getEventData()) // empty datasetId mockConfigSharedState = ["messaging.eventDataset": ""] @@ -100,7 +132,7 @@ class MessagingPublicAPITests: XCTestCase { func testHandleNotificationResponse_missingXDMData() { var data = getEventData() data[MessagingConstants.Event.Data.Key.ADOBE_XDM] = nil - let event = Event(name: "", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: data) + let event = Event(name: "", type: EventType.messaging, source: EventSource.requestContent, data: data) // mock configuration shared state mockRuntime.simulateSharedState(for: (extensionName: "com.adobe.module.configuration", event: event), data: (value: mockConfigSharedState, status: .set)) @@ -114,14 +146,33 @@ class MessagingPublicAPITests: XCTestCase { let edgeEvent = mockRuntime.dispatchedEvents[1] XCTAssertEqual(edgeEvent.type, EventType.edge) - let flattenEdgeEvent = edgeEvent.data?.flattening() - XCTAssertEqual("apns", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProvider"] as? String) - XCTAssertEqual("mockMessageId", flattenEdgeEvent?["xdm.pushNotificationTracking.pushProviderMessageID"] as? String) - XCTAssertEqual(1, flattenEdgeEvent?["xdm.application.launches.value"] as? Int) - XCTAssertEqual("pushTracking.customAction", flattenEdgeEvent?["xdm.eventType"] as? String) - XCTAssertEqual("mockEventDataset", flattenEdgeEvent?["meta.collect.datasetId"] as? String) - XCTAssertEqual("mockCustomActionId", flattenEdgeEvent?["xdm.pushNotificationTracking.customAction.actionID"] as? String) - XCTAssertNil(flattenEdgeEvent?["xdm._experience.customerJourneyManagement.messageExecution.messageExecutionID"]) + + let expectedJSON = #""" + { + "xdm": { + "pushNotificationTracking": { + "pushProvider": "apns", + "pushProviderMessageID": "mockMessageId", + "customAction": { + "actionID": "mockCustomActionId" + } + }, + "application": { + "launches": { + "value": 1 + } + }, + "eventType": "pushTracking.customAction" + }, + "meta": { + "collect": { + "datasetId": "mockEventDataset" + } + } + } + """# + + assertExactMatch(expected: expectedJSON.toAnyCodable()!, actual: edgeEvent.toAnyCodable(), pathOptions: []) } // MARK: - Helpers @@ -133,7 +184,7 @@ class MessagingPublicAPITests: XCTestCase { "journeyVersionInstanceId": "someJourneyVersionInstanceId", "messageID": "567" ]]]]] - let data = [MessagingConstants.Event.Data.Key.MESSAGE_ID: "mockMessageId", + let data = [MessagingConstants.Event.Data.Key.ID: "mockMessageId", MessagingConstants.Event.Data.Key.APPLICATION_OPENED: true, MessagingConstants.Event.Data.Key.EVENT_TYPE: MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION, MessagingConstants.Event.Data.Key.ACTION_ID: "mockCustomActionId", diff --git a/AEPMessaging/Tests/Resources/cachedProposition.json b/AEPMessaging/Tests/Resources/cachedProposition.json new file mode 100644 index 00000000..14f1078a --- /dev/null +++ b/AEPMessaging/Tests/Resources/cachedProposition.json @@ -0,0 +1,124 @@ +{ + "mobileapp://com.ajo.testing/iam/": [ + { + "id": "c2aa4a73-a534-44c2-baa4-a12980e4bb9e", + "scope": "mobileapp://com.ajo.testing/iam/", + "scopeDetails": { + "decisionProvider": "AJO", + "correlationID": "b5095046-7fd7-4961-871f-9d68f2ac335f", + "characteristics": { + "eventToken": "eyJtZXNzYWdlRXhlY3V0aW9uIjp7Im1lc3NhZ2VFeGVjdXRpb25JRCI6Ik5BIiwibWVzc2FnZUlEIjoiYjUwOTUwNDYtN2ZkNy00OTYxLTg3MWYtOWQ2OGYyZGMzMzVmIiwibWVzc2FnZVB1YmxpY2F0aW9uSUQiOiJiNzFlNDhiYS1mNzY3LTQ5NWItOWQxMS01YzA3MTg4NWNkODkiLCJtZXNzYWdlVHlwZSI6Im1hcmtldGluZyIsImNhbXBhaWduSUQiOiI5YzhlYzAzNS02YjNiLTQ3MGUtOGFlNS1lNTM5YzcxMjM4MDkiLCJjYW1wYWlnblZlcnNpb25JRCI6IjdkZGEyZGM2LTE5MjMtNGU2My1iZWFjLTU0ZGM3ODczNjFlYiIsImNhbXBhaWduQWN0aW9uSUQiOiJjN2MxNDk3ZS1lNWEzLTQ0MjMtYWUzNy1iYTc2ZTFlNDQzNDIifSwibWVzc2FnZVByb2ZpbGUiOnsibWVzc2FnZVByb2ZpbGVJRCI6IjQ0YWQ1NTA3LTZlODItNGY2MS05N2U1LTUzMmNhNmZkMDhhOCIsImNoYW5uZWwiOnsiX2lkIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWxzL3dlYiIsIl90eXBlIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWwtdHlwZXMvd2ViIn19fQ==" + }, + "activity": { + "id": "9c8ec035-6b3b-470e-8ae5-e539c7123809#c7c1497e-e5a3-4423-ae37-ba76e1e44342" + } + }, + "items": [ + { + "id": "9d6eff2c-39a7-4aa1-9657-d642e26c5176", + "schema": "https://ns.adobe.com/personalization/ruleset-item", + "data": { + "version": 1, + "rules": [ + { + "condition": { + "definition": { + "conditions": [ + { + "definition": { + "conditions": [ + { + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic.track" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "~source", + "matcher": "eq", + "values": [ + "com.adobe.eventSource.requestContent" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "action", + "matcher": "ex" + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + { + "definition": { + "key": "action", + "matcher": "eq", + "values": [ + "fullscreen" + ] + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + "consequences": [ + { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "type": "schema", + "detail": { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "schema": "https://ns.adobe.com/personalization/message/in-app", + "data": { + "publishedDate": 1691541497, + "expiryDate": 1723163897, + "meta": { + "metaKey": "metaValue" + }, + "mobileParameters": { + "verticalAlign": "center", + "dismissAnimation": "bottom", + "verticalInset": 0, + "backdropOpacity": 0.2, + "cornerRadius": 15, + "gestures": {}, + "horizontalInset": 0, + "uiTakeover": true, + "horizontalAlign": "center", + "width": 100, + "displayAnimation": "bottom", + "backdropColor": "#000000", + "height": 100 + }, + "webParameters": { + "webParamKey": "webParamValue" + }, + "remoteAssets": [ + "urlToAnImage" + ], + "contentType": "text/html", + "content": "Is this thing even on?" + } + } + } + ] + } + ] + } + } + ] + } + ] +} diff --git a/AEPMessaging/Tests/Resources/codeBasedPropositionHtml.json b/AEPMessaging/Tests/Resources/codeBasedPropositionHtml.json new file mode 100644 index 00000000..be9ca916 --- /dev/null +++ b/AEPMessaging/Tests/Resources/codeBasedPropositionHtml.json @@ -0,0 +1,24 @@ +{ + "scope": "mobileapp:\/\/com.steveb.iamStagingTester\/cbeoffers3", + "scopeDetails": { + "activity": { + "id": "7a5fcd59-70a1-4d29-9280-730393c1eb09#988d6414-7c62-42c3-beaa-f2bb13f940c6" + }, + "characteristics": { + "eventToken": "eyJtZXNzYWdlRXhlY3V0aW9uIjp7Im1lc3NhZ2VFeGVjdXRpb25JRCI6Ik5BIiwibWVzc2FnZUlEIjoiNWE1NDMyMGYtZmFkZS00MGE5LThiZDUtYTkwYWE4YzZiMDVlIiwibWVzc2FnZVB1YmxpY2F0aW9uSUQiOiJhN2FjYjJjNi1iZGIyLTQ4Y2YtYjZmMi0zYTc3MmQ2YmFhYmEiLCJtZXNzYWdlVHlwZSI6Im1hcmtldGluZyIsImNhbXBhaWduSUQiOiI3YTVmY2Q1OS03MGExLTRkMjktOTI4MC03MzAzOTNjMWViMDkiLCJjYW1wYWlnblZlcnNpb25JRCI6IjQ4ZDIzNmFhLWRlMTYtNDZmYi1iOGZjLWY2NzI5MzBkYTY1NiIsImNhbXBhaWduQWN0aW9uSUQiOiI5ODhkNjQxNC03YzYyLTQyYzMtYmVhYS1mMmJiMTNmOTQwYzYifSwibWVzc2FnZVByb2ZpbGUiOnsibWVzc2FnZVByb2ZpbGVJRCI6ImVhZGQxYzQzLTI5Y2EtNDVmNC04NmY5LTg5NzgzY2RiMTdmNSIsImNoYW5uZWwiOnsiX2lkIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWxzL3dlYiIsIl90eXBlIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWwtdHlwZXMvd2ViIn19fQ==" + }, + "correlationID": "5a54320f-fade-40a9-8bd5-a90aa8c6b05e", + "decisionProvider": "AJO" + }, + "items": [ + { + "id": "e572a8fa-eada-4d72-a643-ec4de447678c", + "schema": "https:\/\/ns.adobe.com\/personalization\/html-content-item", + "data": { + "content": "

!!Spring sale!!<\/p>

Get 20% off on your first purchase.<\/p> <\/body> <\/html>", + "format": "text/html" + } + } + ], + "id": "d5072be7-5317-4ee4-b52b-1710ab60748f" +} diff --git a/AEPMessaging/Tests/Resources/codeBasedPropositionHtmlContent.json b/AEPMessaging/Tests/Resources/codeBasedPropositionHtmlContent.json new file mode 100644 index 00000000..45f04368 --- /dev/null +++ b/AEPMessaging/Tests/Resources/codeBasedPropositionHtmlContent.json @@ -0,0 +1,4 @@ +{ + "content": "

!!Spring sale!!<\/p>

Get 20% off on your first purchase.<\/p> <\/body> <\/html>", + "format": "text/html" +} diff --git a/AEPMessaging/Tests/Resources/codeBasedPropositionJson.json b/AEPMessaging/Tests/Resources/codeBasedPropositionJson.json new file mode 100644 index 00000000..7132a97f --- /dev/null +++ b/AEPMessaging/Tests/Resources/codeBasedPropositionJson.json @@ -0,0 +1,26 @@ +{ + "scope": "mobileapp:\/\/com.steveb.iamStagingTester\/cbeoffers3", + "scopeDetails": { + "activity": { + "id": "7a5fcd59-70a1-4d29-9280-730393c1eb09#988d6414-7c62-42c3-beaa-f2bb13f940c6" + }, + "characteristics": { + "eventToken": "eyJtZXNzYWdlRXhlY3V0aW9uIjp7Im1lc3NhZ2VFeGVjdXRpb25JRCI6Ik5BIiwibWVzc2FnZUlEIjoiNWE1NDMyMGYtZmFkZS00MGE5LThiZDUtYTkwYWE4YzZiMDVlIiwibWVzc2FnZVB1YmxpY2F0aW9uSUQiOiJhN2FjYjJjNi1iZGIyLTQ4Y2YtYjZmMi0zYTc3MmQ2YmFhYmEiLCJtZXNzYWdlVHlwZSI6Im1hcmtldGluZyIsImNhbXBhaWduSUQiOiI3YTVmY2Q1OS03MGExLTRkMjktOTI4MC03MzAzOTNjMWViMDkiLCJjYW1wYWlnblZlcnNpb25JRCI6IjQ4ZDIzNmFhLWRlMTYtNDZmYi1iOGZjLWY2NzI5MzBkYTY1NiIsImNhbXBhaWduQWN0aW9uSUQiOiI5ODhkNjQxNC03YzYyLTQyYzMtYmVhYS1mMmJiMTNmOTQwYzYifSwibWVzc2FnZVByb2ZpbGUiOnsibWVzc2FnZVByb2ZpbGVJRCI6ImVhZGQxYzQzLTI5Y2EtNDVmNC04NmY5LTg5NzgzY2RiMTdmNSIsImNoYW5uZWwiOnsiX2lkIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWxzL3dlYiIsIl90eXBlIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWwtdHlwZXMvd2ViIn19fQ==" + }, + "correlationID": "5a54320f-fade-40a9-8bd5-a90aa8c6b05e", + "decisionProvider": "AJO" + }, + "items": [ + { + "id": "e572a8fa-eada-4d72-a643-ec4de447678c", + "schema": "https:\/\/ns.adobe.com\/personalization\/html-content-item", + "data": { + "content": { + "category": "wintersale" + }, + "format": "application/json" + } + } + ], + "id": "d5072be7-5317-4ee4-b52b-1710ab60748f" +} diff --git a/AEPMessaging/Tests/Resources/codeBasedPropositionJsonContent.json b/AEPMessaging/Tests/Resources/codeBasedPropositionJsonContent.json new file mode 100644 index 00000000..6c9206ee --- /dev/null +++ b/AEPMessaging/Tests/Resources/codeBasedPropositionJsonContent.json @@ -0,0 +1,6 @@ +{ + "content": { + "category": "wintersale" + }, + "format": "application/json" +} diff --git a/AEPMessaging/Tests/Resources/feedProposition.json b/AEPMessaging/Tests/Resources/feedProposition.json new file mode 100644 index 00000000..f9e23ee7 --- /dev/null +++ b/AEPMessaging/Tests/Resources/feedProposition.json @@ -0,0 +1,82 @@ +{ + "id": "c2aa4a73-a534-44c2-baa4-a12980e5bb9d", + "scope": "mobileapp://com.feeds.testing/feeds/apifeed", + "scopeDetails": { + "decisionProvider": "AJO", + "correlationID": "b5095046-7fd7-4961-871f-9d68f2dc335f", + "characteristics": { + "eventToken": "eyJtZXNzYWdlRXhlY3V0aW9uIjp7Im1lc3NhZ2VFeGVjdXRpb25JRCI6Ik5BIiwibWVzc2FnZUlEIjoiYjUwOTUwNDYtN2ZkNy00OTYxLTg3MWYtOWQ2OGYyZGMzMzVmIiwibWVzc2FnZVB1YmxpY2F0aW9uSUQiOiJiNzFlNDhiYS1mNzY3LTQ5NWItOWQxMS01YzA3MTg4NWNkODkiLCJtZXNzYWdlVHlwZSI6Im1hcmtldGluZyIsImNhbXBhaWduSUQiOiI5YzhlYzAzNS02YjNiLTQ3MGUtOGFlNS1lNTM5YzcxMjM4MDkiLCJjYW1wYWlnblZlcnNpb25JRCI6IjdkZGEyZGM2LTE5MjMtNGU2My1iZWFjLTU0ZGM3ODczNjFlYiIsImNhbXBhaWduQWN0aW9uSUQiOiJjN2MxNDk3ZS1lNWEzLTQ0MjMtYWUzNy1iYTc2ZTFlNDQzNDIifSwibWVzc2FnZVByb2ZpbGUiOnsibWVzc2FnZVByb2ZpbGVJRCI6IjQ0YWQ1NTA3LTZlODItNGY2MS05N2U1LTUzMmNhNmZkMDhhOCIsImNoYW5uZWwiOnsiX2lkIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWxzL3dlYiIsIl90eXBlIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWwtdHlwZXMvd2ViIn19fQ==" + }, + "activity": { + "id": "9c8ec035-6b3b-470e-8ae5-e539c7123809#c7c1497e-e5a3-4423-ae37-ba76e1e44342" + } + }, + "items": [ + { + "id": "9d6eff2c-39a7-4aa1-9657-d642e26c5176", + "schema": "https://ns.adobe.com/personalization/ruleset-item", + "data": { + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "ge", + "values": [ + 1691541497 + ] + } + }, + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "le", + "values": [ + 1723163897 + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "type": "schema", + "detail": { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "schema": "https://ns.adobe.com/personalization/message/feed-item", + "data": { + "expiryDate": 1723163897, + "meta": { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + }, + "content": { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + }, + "contentType": "application/json", + "publishedDate": 1691541497 + } + } + } + ] + } + ] + } + } + ] +} diff --git a/AEPMessaging/Tests/Resources/feedPropositionContent.json b/AEPMessaging/Tests/Resources/feedPropositionContent.json new file mode 100644 index 00000000..8ca575a8 --- /dev/null +++ b/AEPMessaging/Tests/Resources/feedPropositionContent.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "ge", + "values": [ + 1691541497 + ] + } + }, + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "le", + "values": [ + 1723163897 + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "type": "schema", + "detail": { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "schema": "https://ns.adobe.com/personalization/message/feed-item", + "data": { + "expiryDate": 1723163897, + "meta": { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + }, + "content": { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + }, + "contentType": "application/json", + "publishedDate": 1691541497 + } + } + } + ] + } + ] +} diff --git a/AEPMessaging/Tests/Resources/inappPropositionV2.json b/AEPMessaging/Tests/Resources/inappPropositionV2.json new file mode 100644 index 00000000..fb3bb9fa --- /dev/null +++ b/AEPMessaging/Tests/Resources/inappPropositionV2.json @@ -0,0 +1,120 @@ +{ + "id": "c2aa4a73-a534-44c2-baa4-a12980e4bb9e", + "scope": "mobileapp://com.ajo.testing/iam/", + "scopeDetails": { + "decisionProvider": "AJO", + "correlationID": "b5095046-7fd7-4961-871f-9d68f2ac335f", + "characteristics": { + "eventToken": "eyJtZXNzYWdlRXhlY3V0aW9uIjp7Im1lc3NhZ2VFeGVjdXRpb25JRCI6Ik5BIiwibWVzc2FnZUlEIjoiYjUwOTUwNDYtN2ZkNy00OTYxLTg3MWYtOWQ2OGYyZGMzMzVmIiwibWVzc2FnZVB1YmxpY2F0aW9uSUQiOiJiNzFlNDhiYS1mNzY3LTQ5NWItOWQxMS01YzA3MTg4NWNkODkiLCJtZXNzYWdlVHlwZSI6Im1hcmtldGluZyIsImNhbXBhaWduSUQiOiI5YzhlYzAzNS02YjNiLTQ3MGUtOGFlNS1lNTM5YzcxMjM4MDkiLCJjYW1wYWlnblZlcnNpb25JRCI6IjdkZGEyZGM2LTE5MjMtNGU2My1iZWFjLTU0ZGM3ODczNjFlYiIsImNhbXBhaWduQWN0aW9uSUQiOiJjN2MxNDk3ZS1lNWEzLTQ0MjMtYWUzNy1iYTc2ZTFlNDQzNDIifSwibWVzc2FnZVByb2ZpbGUiOnsibWVzc2FnZVByb2ZpbGVJRCI6IjQ0YWQ1NTA3LTZlODItNGY2MS05N2U1LTUzMmNhNmZkMDhhOCIsImNoYW5uZWwiOnsiX2lkIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWxzL3dlYiIsIl90eXBlIjoiaHR0cHM6Ly9ucy5hZG9iZS5jb20veGRtL2NoYW5uZWwtdHlwZXMvd2ViIn19fQ==" + }, + "activity": { + "id": "9c8ec035-6b3b-470e-8ae5-e539c7123809#c7c1497e-e5a3-4423-ae37-ba76e1e44342" + } + }, + "items": [ + { + "id": "9d6eff2c-39a7-4aa1-9657-d642e26c5176", + "schema": "https://ns.adobe.com/personalization/ruleset-item", + "data": { + "version": 1, + "rules": [ + { + "condition": { + "definition": { + "conditions": [ + { + "definition": { + "conditions": [ + { + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic.track" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "~source", + "matcher": "eq", + "values": [ + "com.adobe.eventSource.requestContent" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "action", + "matcher": "ex" + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + { + "definition": { + "key": "action", + "matcher": "eq", + "values": [ + "fullscreen" + ] + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + "consequences": [ + { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "type": "schema", + "detail": { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "schema": "https://ns.adobe.com/personalization/message/in-app", + "data": { + "publishedDate": 1691541497, + "expiryDate": 1723163897, + "meta": { + "metaKey": "metaValue" + }, + "mobileParameters": { + "verticalAlign": "center", + "dismissAnimation": "bottom", + "verticalInset": 0, + "backdropOpacity": 0.2, + "cornerRadius": 15, + "gestures": {}, + "horizontalInset": 0, + "uiTakeover": true, + "horizontalAlign": "center", + "width": 100, + "displayAnimation": "bottom", + "backdropColor": "#000000", + "height": 100 + }, + "webParameters": { + "webParamKey": "webParamValue" + }, + "remoteAssets": [ + "urlToAnImage" + ], + "contentType": "text/html", + "content": "Is this thing even on?" + } + } + } + ] + } + ] + } + } + ] +} diff --git a/AEPMessaging/Tests/Resources/inappPropositionV2Content.json b/AEPMessaging/Tests/Resources/inappPropositionV2Content.json new file mode 100644 index 00000000..e5438e02 --- /dev/null +++ b/AEPMessaging/Tests/Resources/inappPropositionV2Content.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "definition": { + "conditions": [ + { + "definition": { + "conditions": [ + { + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic.track" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "~source", + "matcher": "eq", + "values": [ + "com.adobe.eventSource.requestContent" + ] + }, + "type": "matcher" + }, + { + "definition": { + "key": "action", + "matcher": "ex" + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + { + "definition": { + "key": "action", + "matcher": "eq", + "values": [ + "fullscreen" + ] + }, + "type": "matcher" + } + ], + "logic": "and" + }, + "type": "group" + }, + "consequences": [ + { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "type": "schema", + "detail": { + "id": "6ac78390-84e3-4d35-b798-8e7080e69a67", + "schema": "https://ns.adobe.com/personalization/message/in-app", + "data": { + "publishedDate": 1691541497, + "expiryDate": 1723163897, + "meta": { + "metaKey": "metaValue" + }, + "mobileParameters": { + "verticalAlign": "center", + "dismissAnimation": "bottom", + "verticalInset": 0, + "backdropOpacity": 0.2, + "cornerRadius": 15, + "gestures": {}, + "horizontalInset": 0, + "uiTakeover": true, + "horizontalAlign": "center", + "width": 100, + "displayAnimation": "bottom", + "backdropColor": "#000000", + "height": 100 + }, + "webParameters": { + "webParamKey": "webParamValue" + }, + "remoteAssets": [ "urlToAnImage" ], + "contentType": "text/html", + "content": "Is this thing even on?" + } + } + } + ] + } + ] +} diff --git a/AEPMessaging/Tests/Resources/mockPropositionItem.json b/AEPMessaging/Tests/Resources/mockPropositionItem.json new file mode 100644 index 00000000..b4bc3884 --- /dev/null +++ b/AEPMessaging/Tests/Resources/mockPropositionItem.json @@ -0,0 +1,28 @@ +{ + "publishedDate": 1691541497, + "expiryDate": 1723163897, + "meta": { + "metaKey": "metaValue" + }, + "mobileParameters": { + "verticalAlign": "center", + "dismissAnimation": "bottom", + "verticalInset": 0, + "backdropOpacity": 0.2, + "cornerRadius": 15, + "gestures": {}, + "horizontalInset": 0, + "uiTakeover": true, + "horizontalAlign": "center", + "width": 100, + "displayAnimation": "bottom", + "backdropColor": "#000000", + "height": 100 + }, + "webParameters": { + "webParamKey": "webParamValue" + }, + "remoteAssets": [ "https://blog.adobe.com/en/publish/2020/05/28/media_1cc0fcc19cf0e64decbceb3a606707a3ad23f51dd.png" ], + "contentType": "text/html", + "content": "Is this thing even on?" +} diff --git a/AEPMessaging/Tests/Resources/ruleWithNoConsequence.json b/AEPMessaging/Tests/Resources/ruleWithNoConsequence.json new file mode 100644 index 00000000..ce588fe4 --- /dev/null +++ b/AEPMessaging/Tests/Resources/ruleWithNoConsequence.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "ge", + "values": [ + 1691541497 + ] + } + }, + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "le", + "values": [ + 1723163897 + ] + } + } + ] + } + }, + "consequences": [] + } + ] +} diff --git a/AEPMessaging/Tests/Resources/ruleWithUnknownConsequenceSchema.json b/AEPMessaging/Tests/Resources/ruleWithUnknownConsequenceSchema.json new file mode 100644 index 00000000..0eb30353 --- /dev/null +++ b/AEPMessaging/Tests/Resources/ruleWithUnknownConsequenceSchema.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "ge", + "values": [ + 1691541497 + ] + } + }, + { + "type": "matcher", + "definition": { + "key": "~timestampu", + "matcher": "le", + "values": [ + 1723163897 + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "type": "schema", + "detail": { + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "schema": "https://ns.adobe.com/personalization/message/iamunknown", + "data": { + "expiryDate": 1723163897, + "meta": { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + }, + "content": { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + }, + "contentType": "application/json", + "publishedDate": 1691541497 + } + } + } + ] + } + ] +} diff --git a/AEPMessaging/Tests/TestHelpers/JSONFileLoader.swift b/AEPMessaging/Tests/TestHelpers/JSONFileLoader.swift index ffad997b..cef6c954 100644 --- a/AEPMessaging/Tests/TestHelpers/JSONFileLoader.swift +++ b/AEPMessaging/Tests/TestHelpers/JSONFileLoader.swift @@ -25,4 +25,11 @@ class JSONFileLoader { return jsonString } + + static func getRulesJsonFromFile(_ fileName: String) -> [String: Any] { + let jsonString = getRulesStringFromFile(fileName) + let jsonData = Data(jsonString.utf8) + let jsonMap = try? JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as? [String: Any] + return jsonMap ?? [:] + } } diff --git a/AEPMessaging/Tests/TestHelpers/MockCache.swift b/AEPMessaging/Tests/TestHelpers/MockCache.swift index 53e4b244..2afa613b 100644 --- a/AEPMessaging/Tests/TestHelpers/MockCache.swift +++ b/AEPMessaging/Tests/TestHelpers/MockCache.swift @@ -11,6 +11,7 @@ */ @testable import AEPServices +@testable import AEPMessaging import Foundation import XCTest diff --git a/AEPMessaging/Tests/TestHelpers/MockFeedRulesEngine.swift b/AEPMessaging/Tests/TestHelpers/MockFeedRulesEngine.swift new file mode 100644 index 00000000..b6cc8ab7 --- /dev/null +++ b/AEPMessaging/Tests/TestHelpers/MockFeedRulesEngine.swift @@ -0,0 +1,35 @@ +/* + Copyright 2023 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. + */ + +@testable import AEPCore +@testable import AEPMessaging +@testable import AEPServices +import AEPTestUtils +import Foundation + +class MockFeedRulesEngine: FeedRulesEngine { + let mockRuntime: TestableExtensionRuntime + let mockRulesEngine: MockLaunchRulesEngine + + init(name _: String, runtime: ExtensionRuntime) { + mockRuntime = TestableExtensionRuntime() + mockRulesEngine = MockLaunchRulesEngine(name: "mockFeedRulesEngine", extensionRuntime: runtime) + super.init(extensionRuntime: mockRuntime, launchRulesEngine: mockRulesEngine) + // super.init(name: name, extensionRuntime: runtime) + } + + override init(extensionRuntime: ExtensionRuntime, launchRulesEngine: LaunchRulesEngine) { + mockRuntime = TestableExtensionRuntime() + mockRulesEngine = MockLaunchRulesEngine(name: "mockRulesEngine", extensionRuntime: extensionRuntime) + super.init(extensionRuntime: extensionRuntime, launchRulesEngine: launchRulesEngine) + } +} diff --git a/AEPMessaging/Tests/TestHelpers/MockLaunchRulesEngine.swift b/AEPMessaging/Tests/TestHelpers/MockLaunchRulesEngine.swift index 7bd6d829..715a868a 100644 --- a/AEPMessaging/Tests/TestHelpers/MockLaunchRulesEngine.swift +++ b/AEPMessaging/Tests/TestHelpers/MockLaunchRulesEngine.swift @@ -14,6 +14,8 @@ import Foundation class MockLaunchRulesEngine: LaunchRulesEngine { + var ruleConsequences: [RuleConsequence] = [] + override init(name: String, extensionRuntime: ExtensionRuntime) { super.init(name: name, extensionRuntime: extensionRuntime) } @@ -26,11 +28,19 @@ class MockLaunchRulesEngine: LaunchRulesEngine { return event } + var evaluateCalled: Bool = false + var paramEvaluateEvent: Event? + override func evaluate(event: Event) -> [RuleConsequence]? { + evaluateCalled = true + paramEvaluateEvent = event + return ruleConsequences + } + var replaceRulesCalled: Bool = false - var paramRules: [LaunchRule]? + var paramReplaceRulesRules: [LaunchRule]? override func replaceRules(with rules: [LaunchRule]) { replaceRulesCalled = true - paramRules = rules + paramReplaceRulesRules = rules } var addRulesCalled: Bool = false diff --git a/AEPMessaging/Tests/TestHelpers/MockMessage.swift b/AEPMessaging/Tests/TestHelpers/MockMessage.swift index a608ec3a..bd7d6fe0 100644 --- a/AEPMessaging/Tests/TestHelpers/MockMessage.swift +++ b/AEPMessaging/Tests/TestHelpers/MockMessage.swift @@ -32,7 +32,7 @@ class MockMessage: Message { var trackCalled = false var paramTrackInteraction: String? var paramTrackEventType: MessagingEdgeEventType? - override func track(_ interaction: String?, withEdgeEventType eventType: MessagingEdgeEventType) { + override func track(_ interaction: String? = nil, withEdgeEventType eventType: MessagingEdgeEventType) { trackCalled = true paramTrackInteraction = interaction paramTrackEventType = eventType diff --git a/AEPMessaging/Tests/TestHelpers/MockMessaging.swift b/AEPMessaging/Tests/TestHelpers/MockMessaging.swift index 62b2e515..cb83ec24 100644 --- a/AEPMessaging/Tests/TestHelpers/MockMessaging.swift +++ b/AEPMessaging/Tests/TestHelpers/MockMessaging.swift @@ -12,6 +12,7 @@ @testable import AEPCore @testable import AEPMessaging +import AEPTestUtils import Foundation class MockMessaging: Messaging { @@ -25,4 +26,11 @@ class MockMessaging: Messaging { var paramInteraction: String? var paramMessage: Message? var sendPropositionInteractionCalled = false + + var propositionInfoForMessageIdCalled = false + var propositionInfoForMessageIdReturnValue: PropositionInfo? + override func propositionInfoFor(messageId: String) -> PropositionInfo? { + propositionInfoForMessageIdCalled = true + return propositionInfoForMessageIdReturnValue + } } diff --git a/AEPMessaging/Tests/TestHelpers/MockMessagingRulesEngine.swift b/AEPMessaging/Tests/TestHelpers/MockMessagingRulesEngine.swift index 4edbfb85..d931d052 100644 --- a/AEPMessaging/Tests/TestHelpers/MockMessagingRulesEngine.swift +++ b/AEPMessaging/Tests/TestHelpers/MockMessagingRulesEngine.swift @@ -13,6 +13,7 @@ @testable import AEPCore @testable import AEPMessaging @testable import AEPServices +import AEPTestUtils import Foundation class MockMessagingRulesEngine: MessagingRulesEngine { @@ -24,15 +25,14 @@ class MockMessagingRulesEngine: MessagingRulesEngine { mockCache = MockCache(name: "mockCache") mockRuntime = TestableExtensionRuntime() mockRulesEngine = MockLaunchRulesEngine(name: "mockRulesEngine", extensionRuntime: runtime) - super.init(extensionRuntime: mockRuntime, rulesEngine: mockRulesEngine, cache: mockCache) - // super.init(name: name, extensionRuntime: runtime) + super.init(extensionRuntime: mockRuntime, launchRulesEngine: mockRulesEngine, cache: mockCache) } - override init(extensionRuntime: ExtensionRuntime, rulesEngine: LaunchRulesEngine, cache: Cache) { + override init(extensionRuntime: ExtensionRuntime, launchRulesEngine: LaunchRulesEngine, cache: Cache) { mockCache = MockCache(name: "mockCache") mockRuntime = TestableExtensionRuntime() mockRulesEngine = MockLaunchRulesEngine(name: "mockRulesEngine", extensionRuntime: extensionRuntime) - super.init(extensionRuntime: extensionRuntime, rulesEngine: rulesEngine, cache: cache) + super.init(extensionRuntime: extensionRuntime, launchRulesEngine: launchRulesEngine, cache: cache) } var processCalled = false @@ -41,24 +41,4 @@ class MockMessagingRulesEngine: MessagingRulesEngine { processCalled = true paramProcessEvent = event } - - var loadPropositionsCalled = false - var paramLoadPropositionsPropositions: [PropositionPayload]? - var paramLoadPropositionsClearExisting: Bool? - var paramLoadPropositionsPersistChanges: Bool? - var paramLoadPropositionsExpectedScope: String? - override func loadPropositions(_ propositions: [PropositionPayload]?, clearExisting: Bool, persistChanges: Bool = true, expectedScope: String) { - loadPropositionsCalled = true - paramLoadPropositionsPropositions = propositions - paramLoadPropositionsClearExisting = clearExisting - paramLoadPropositionsPersistChanges = persistChanges - paramLoadPropositionsExpectedScope = expectedScope - } - - var propositionInfoForMessageIdCalled = false - var propositionInfoForMessageIdReturnValue: PropositionInfo? - override func propositionInfoForMessageId(_ messageId: String) -> PropositionInfo? { - propositionInfoForMessageIdCalled = true - return propositionInfoForMessageIdReturnValue - } } diff --git a/AEPMessaging/Tests/TestHelpers/TestableExtensionRuntime.swift b/AEPMessaging/Tests/TestHelpers/TestableExtensionRuntime.swift index f71f62bb..7ad86368 100644 --- a/AEPMessaging/Tests/TestHelpers/TestableExtensionRuntime.swift +++ b/AEPMessaging/Tests/TestHelpers/TestableExtensionRuntime.swift @@ -183,7 +183,7 @@ public class TestableExtensionRuntime: ExtensionRuntime { public extension TestableExtensionRuntime { var firstEvent: Event? { - dispatchedEvents[0] + dispatchedEvents.count > 0 ? dispatchedEvents[0] : nil } var secondEvent: Event? { diff --git a/AEPMessaging/Tests/TestHelpers/TestableMessagingMobileParameters.swift b/AEPMessaging/Tests/TestHelpers/TestableMessagingMobileParameters.swift index fdfa5d81..21cf1677 100644 --- a/AEPMessaging/Tests/TestHelpers/TestableMessagingMobileParameters.swift +++ b/AEPMessaging/Tests/TestHelpers/TestableMessagingMobileParameters.swift @@ -53,7 +53,9 @@ class TestableMobileParameters { var eventData: [String: Any] = [ MessagingConstants.Event.Data.Key.TRIGGERED_CONSEQUENCE: [ MessagingConstants.Event.Data.Key.DETAIL: [ - MessagingConstants.Event.Data.Key.IAM.MOBILE_PARAMETERS: mobileParameters + MessagingConstants.Event.Data.Key.DATA: [ + "mobileParameters": mobileParameters + ] ] ] ] diff --git a/AEPMessaging/Tests/TestHelpers/TestableNetworkService.swift b/AEPMessaging/Tests/TestHelpers/TestableNetworkService.swift deleted file mode 100644 index ea5ea507..00000000 --- a/AEPMessaging/Tests/TestHelpers/TestableNetworkService.swift +++ /dev/null @@ -1,41 +0,0 @@ -/* - Copyright 2021 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 AEPServices -import Foundation - -typealias NetworkResponse = (data: Data?, response: HTTPURLResponse?, error: Error?) -typealias RequestResolver = (NetworkRequest) -> NetworkResponse? - -class TestableNetworkService: Networking { - var requests: [NetworkRequest] = [] - var resolvers: [RequestResolver] = [] - - init() {} - - func connectAsync(networkRequest: NetworkRequest, completionHandler: ((HttpConnection) -> Void)?) { - requests.append(networkRequest) - for resolver in resolvers { - if let response = resolver(networkRequest) { - let httpConnection = HttpConnection(data: response.data, response: response.response, error: response.error) - completionHandler?(httpConnection) - return - } - } - - completionHandler?(HttpConnection(data: nil, response: nil, error: nil)) - } - - func mock(resolver: @escaping RequestResolver) { - resolvers += [resolver] - } -} diff --git a/AEPMessaging/Tests/UnitTests/Array+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/Array+MessagingTests.swift new file mode 100644 index 00000000..b5d2114c --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/Array+MessagingTests.swift @@ -0,0 +1,93 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices + +class ArrayMessagingTests: XCTestCase { + + class Shape: Equatable { + let name: String + let vertices: Int + + init(_ name: String, withVertices vertices: Int) { + self.name = name + self.vertices = vertices + } + + static func == (lhs: Shape, rhs: Shape) -> Bool { + return lhs.name == rhs.name && lhs.vertices == rhs.vertices + } + } + + var shapes: [Shape] = [] + var array1: [String] = [ "value1", "value2", "value3" ] + + override func setUp() { + shapes = [ + Shape("triangle", withVertices: 3), + Shape("square", withVertices: 4), + Shape("pentagon", withVertices: 5) + ] + } + + func testArrayMinus() { + let arrayToMinus = ["value2", "value4"] + + // test + let result = array1.minus(arrayToMinus).sorted() + + // verify + XCTAssertEqual(2, result.count) + XCTAssertEqual("value1", result[0]) + XCTAssertEqual("value3", result[1]) + } + + func testArrayMinusAll() { + let arrayToMinus = ["value2", "value3", "value1"] + + // test + let result = array1.minus(arrayToMinus) + + // verify + XCTAssertTrue(result.isEmpty) + } + + func testToDictionary() { + // test + let dict: [String: [Shape]] = shapes.toDictionary { String($0.vertices) } + + // verify + XCTAssertEqual(3, dict.count) + XCTAssertEqual([shapes[0]], dict["3"]) + XCTAssertEqual([shapes[1]], dict["4"]) + XCTAssertEqual([shapes[2]], dict["5"]) + } + + func testToDictionaryExistingKey() { + shapes.append(Shape("rectangle", withVertices: 4)) + + // test + let dict: [String: [Shape]] = shapes.toDictionary { String($0.vertices) } + + // verify + XCTAssertEqual(3, dict.count) + XCTAssertEqual([shapes[0]], dict["3"]) + XCTAssertEqual(2, dict["4"]?.count) + XCTAssertEqual(shapes[1], dict["4"]?[0]) + XCTAssertEqual(shapes[3], dict["4"]?[1]) + XCTAssertEqual([shapes[2]], dict["5"]) + } +} diff --git a/AEPMessaging/Tests/UnitTests/Bundle+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/Bundle+MessagingTests.swift new file mode 100644 index 00000000..cbae66bd --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/Bundle+MessagingTests.swift @@ -0,0 +1,30 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging + +class BundleMessagingTests: XCTestCase { + func testMobileAppSurface() throws { + // setup + let base = "mobileapp://" + let bundleIdentifier = Bundle.main.bundleIdentifier! + + // test + let result = Bundle.main.mobileappSurface + + // verify + XCTAssertEqual("\(base)\(bundleIdentifier)", result) + } +} diff --git a/AEPMessaging/Tests/UnitTests/Cache+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/Cache+MessagingTests.swift new file mode 100644 index 00000000..42258c6d --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/Cache+MessagingTests.swift @@ -0,0 +1,285 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class CacheMessagingTests: XCTestCase { + let mockCache = MockCache(name: "mockCache") + var mockSurface: Surface! + + var mockProposition: Proposition! + var mockPropositionItem: PropositionItem! + + override func setUp() { + let propositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + mockPropositionItem = PropositionItem(itemId: "inapp2", schema: .ruleset, itemData: propositionContent) + mockProposition = Proposition(uniqueId: "inapp2", scope: "inapp2", scopeDetails: ["key": "value"], items: [mockPropositionItem]) + mockSurface = Surface(uri: "inapp2") + } + + func getPropositionCacheEntry(_ seededPropositions: [String: [Proposition]]? = nil) -> CacheEntry? { + let seededPropositions = seededPropositions ?? [ + mockSurface.uri: [mockProposition] + ] + + let encoder = JSONEncoder() + guard let cacheData = try? encoder.encode(seededPropositions) else { + return nil + } + + return CacheEntry(data: cacheData, expiry: .never, metadata: nil) + } + + func testPropositionsHappy() throws { + // setup + mockCache.getReturnValue = getPropositionCacheEntry() + + // test + let result = mockCache.propositions + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(1, result?.count) + let propForSurface = result?[mockSurface] + XCTAssertNotNil(propForSurface) + let firstProp = propForSurface?.first + XCTAssertNotNil(firstProp) + XCTAssertEqual("inapp2", firstProp?.uniqueId) + XCTAssertEqual("inapp2", firstProp?.scope) + XCTAssertEqual("value", firstProp?.scopeDetails["key"] as? String) + XCTAssertEqual(1, firstProp?.items.count) + let propItem = firstProp?.items.first + XCTAssertEqual("inapp2", propItem?.itemId) + XCTAssertEqual(.ruleset, propItem?.schema) + XCTAssertNotNil(propItem?.itemData) + XCTAssertEqual(2, propItem?.itemData.count) + XCTAssertNotNil(propItem?.itemData["rules"]) + XCTAssertNotNil(propItem?.itemData["version"]) + } + + func testPropositionsNoneInCache() throws { + // setup + mockCache.getReturnValue = nil + + // test + let result = mockCache.propositions + + // verify + XCTAssertNil(result) + } + + func testPropositionsCachedItemsAreNotDecodable() throws { + // setup + mockCache.getReturnValue = CacheEntry(data: "i am not valid propositions".data(using: .utf8)!, expiry: .never, metadata: nil) + + // test + let result = mockCache.propositions + + // verify + XCTAssertNil(result) + } + + func testUpdatePropositionsHappy() throws { + // setup + let newSurface = Surface(uri: "inapp4") + let newPropositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + let newPropositionItem = PropositionItem(itemId: "inapp4", schema: .ruleset, itemData: newPropositionContent) + let newProposition = Proposition(uniqueId: "inapp4", scope: "inapp4", scopeDetails: ["key": "value"], items: [newPropositionItem]) + let newProps: [Surface: [Proposition]] = [ + newSurface: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps) + + // verify + XCTAssertTrue(mockCache.setCalled) + XCTAssertEqual("propositions", mockCache.setParamKey) + guard let setParamCache = mockCache.setParamEntry else { + XCTFail("no cache parameter in 'set' call") + return + } + let decoder = JSONDecoder() + guard let decodedSetParam = try? decoder.decode([String: [Proposition]].self, from: setParamCache.data) else { + XCTFail("failed to decode cache parameter sent during 'set' call") + return + } + XCTAssertEqual(1, decodedSetParam.count) + let paramProp = decodedSetParam["inapp4"]?.first + XCTAssertEqual("inapp4", paramProp?.uniqueId) + XCTAssertEqual("inapp4", paramProp?.scope) + XCTAssertEqual(1, paramProp?.items.count) + } + + func testUpdatePropositionsExistingPropositions() throws { + // setup + mockCache.getReturnValue = getPropositionCacheEntry() + let newSurface = Surface(uri: "inapp4") + let newPropositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + let newPropositionItem = PropositionItem(itemId: "inapp4", schema: .ruleset, itemData: newPropositionContent) + let newProposition = Proposition(uniqueId: "inapp4", scope: "inapp4", scopeDetails: ["key": "value"], items: [newPropositionItem]) + let newProps: [Surface: [Proposition]] = [ + newSurface: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps) + + // verify + XCTAssertTrue(mockCache.setCalled) + XCTAssertEqual("propositions", mockCache.setParamKey) + guard let setParamCache = mockCache.setParamEntry else { + XCTFail("no cache parameter in 'set' call") + return + } + let decoder = JSONDecoder() + guard let decodedSetParam = try? decoder.decode([String: [Proposition]].self, from: setParamCache.data) else { + XCTFail("failed to decode cache parameter sent during 'set' call") + return + } + XCTAssertEqual(2, decodedSetParam.count) + } + + func testUpdatePropositionsRemovingSurfaces() throws { + // setup + mockCache.getReturnValue = getPropositionCacheEntry() + let newSurface = Surface(uri: "inapp4") + let newPropositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + let newPropositionItem = PropositionItem(itemId: "inapp4", schema: .ruleset, itemData: newPropositionContent) + let newProposition = Proposition(uniqueId: "inapp4", scope: "inapp4", scopeDetails: ["key": "value"], items: [newPropositionItem]) + let newProps: [Surface: [Proposition]] = [ + newSurface: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps, removing: [mockSurface]) + + // verify + XCTAssertTrue(mockCache.setCalled) + XCTAssertEqual("propositions", mockCache.setParamKey) + guard let setParamCache = mockCache.setParamEntry else { + XCTFail("no cache parameter in 'set' call") + return + } + let decoder = JSONDecoder() + guard let decodedSetParam = try? decoder.decode([String: [Proposition]].self, from: setParamCache.data) else { + XCTFail("failed to decode cache parameter sent during 'set' call") + return + } + XCTAssertEqual(1, decodedSetParam.count) + let paramProp = decodedSetParam["inapp4"]?.first + XCTAssertEqual("inapp4", paramProp?.uniqueId) + XCTAssertEqual("inapp4", paramProp?.scope) + XCTAssertEqual(1, paramProp?.items.count) + } + + func testUpdatePropositionsNoNewPropositionsClearExisting() throws { + // setup + mockCache.getReturnValue = getPropositionCacheEntry() + + // test + mockCache.updatePropositions(nil, removing: [mockSurface]) + + // verify + XCTAssertTrue(mockCache.removeCalled) + XCTAssertFalse(mockCache.setCalled) + } + + func testUpdatePropositionsOverwriteExistingPropositions() throws { + // setup + mockCache.getReturnValue = getPropositionCacheEntry() + let newSurface = mockSurface + let newPropositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + let newPropositionItem = PropositionItem(itemId: "inapp4", schema: .ruleset, itemData: newPropositionContent) + let newProposition = Proposition(uniqueId: "inapp4", scope: "inapp4", scopeDetails: ["key": "value"], items: [newPropositionItem]) + let newProps: [Surface: [Proposition]] = [ + newSurface!: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps) + + // verify + XCTAssertTrue(mockCache.setCalled) + XCTAssertEqual("propositions", mockCache.setParamKey) + guard let setParamCache = mockCache.setParamEntry else { + XCTFail("no cache parameter in 'set' call") + return + } + let decoder = JSONDecoder() + guard let decodedSetParam = try? decoder.decode([String: [Proposition]].self, from: setParamCache.data) else { + XCTFail("failed to decode cache parameter sent during 'set' call") + return + } + XCTAssertEqual(1, decodedSetParam.count) + let paramProp = decodedSetParam["inapp2"]?.first + XCTAssertEqual("inapp4", paramProp?.uniqueId) + XCTAssertEqual("inapp4", paramProp?.scope) + XCTAssertEqual(1, paramProp?.items.count) + } + + // TODO: how to get encoding to fail (or mock it) + func testUpdatePropositionsBadFormatEncodeFailure() throws { + // setup + let newSurface = Surface(uri: "inapp4") + let newProposition = Proposition(uniqueId: "", scope: "", scopeDetails: ["": ""], items: []) + let newProps: [Surface: [Proposition]] = [ + newSurface: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps) + + // verify + XCTAssertFalse(mockCache.removeCalled) + // set should not be called but is currently because encoding doesn't fail + // XCTAssertFalse(mockCache.setCalled) + } + + func testUpdatePropositionsSetThrows() throws { + // setup + mockCache.setShouldThrow = true + let newSurface = Surface(uri: "inapp4") + let newPropositionContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + let newPropositionItem = PropositionItem(itemId: "inapp4", schema: .ruleset, itemData: newPropositionContent) + let newProposition = Proposition(uniqueId: "inapp4", scope: "inapp4", scopeDetails: ["key": "value"], items: [newPropositionItem]) + let newProps: [Surface: [Proposition]] = [ + newSurface: [newProposition] + ] + + // test + mockCache.updatePropositions(newProps) + + // verify + XCTAssertTrue(mockCache.setCalled) + XCTAssertEqual("propositions", mockCache.setParamKey) + guard let setParamCache = mockCache.setParamEntry else { + XCTFail("no cache parameter in 'set' call") + return + } + let decoder = JSONDecoder() + guard let decodedSetParam = try? decoder.decode([String: [Proposition]].self, from: setParamCache.data) else { + XCTFail("failed to decode cache parameter sent during 'set' call") + return + } + XCTAssertEqual(1, decodedSetParam.count) + let paramProp = decodedSetParam["inapp4"]?.first + XCTAssertEqual("inapp4", paramProp?.uniqueId) + XCTAssertEqual("inapp4", paramProp?.scope) + XCTAssertEqual(1, paramProp?.items.count) + } +} diff --git a/AEPMessaging/Tests/UnitTests/Dictionary+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/Dictionary+MessagingTests.swift new file mode 100644 index 00000000..d6029876 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/Dictionary+MessagingTests.swift @@ -0,0 +1,107 @@ +/* + Copyright 2021 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 Foundation +import XCTest + +@testable import AEPMessaging + +class DictionaryMessagingTests: XCTestCase { + var dictionary1: [String: Any] = [ + "key": "value", + "key2": "value2", + "number": 1 + ] + + var dictionary2: [String: Any] = [ + "key": "newValue", + "key3": "value3", + "number": 552 + ] + + var dictionary3: [String: [Any]] = [ + "key4": ["value4a", "value4b"], + "key5": ["value5"] + ] + + func testMerge() throws { + // test + dictionary1.mergeXdm(rhs: dictionary2) + + // verify + XCTAssertEqual(4, dictionary1.count) + XCTAssertEqual("newValue", dictionary1["key"] as? String) + XCTAssertEqual("value2", dictionary1["key2"] as? String) + XCTAssertEqual("value3", dictionary1["key3"] as? String) + XCTAssertEqual(552, dictionary1["number"] as? Int) + } + + func testAddArray() { + let arrayToAdd = ["value4c"] + + // test + dictionary3.addArray(arrayToAdd, forKey: "key4") + + // verify + XCTAssertEqual(3, dictionary3["key4"]?.count) + XCTAssertEqual("value4a", dictionary3["key4"]?[0] as? String) + XCTAssertEqual("value4b", dictionary3["key4"]?[1] as? String) + XCTAssertEqual("value4c", dictionary3["key4"]?[2] as? String) + } + + func testAddArrayKeyNotPresent() { + let arrayToAdd = [42] + + // test + dictionary3.addArray(arrayToAdd, forKey: "key6") + + // verify + XCTAssertEqual(1, dictionary3["key6"]?.count) + XCTAssertEqual(42, dictionary3["key6"]?[0] as? Int) + } + + func testAddArrayEmpty() { + let arrayToAdd: [String] = [] + + // test + dictionary3.addArray(arrayToAdd, forKey: "key4") + + // verify + XCTAssertEqual(2, dictionary3["key4"]?.count) + XCTAssertEqual("value4a", dictionary3["key4"]?[0] as? String) + XCTAssertEqual("value4b", dictionary3["key4"]?[1] as? String) + } + + func testAdd() { + let elementToAdd = "value4c" + + // test + dictionary3.add(elementToAdd, forKey: "key4") + + // verify + XCTAssertEqual(3, dictionary3["key4"]?.count) + XCTAssertEqual("value4a", dictionary3["key4"]?[0] as? String) + XCTAssertEqual("value4b", dictionary3["key4"]?[1] as? String) + XCTAssertEqual("value4c", dictionary3["key4"]?[2] as? String) + } + + func testAddKeyNotPresent() { + let elementToAdd = 42 + + // test + dictionary3.add(elementToAdd, forKey: "key6") + + // verify + XCTAssertEqual(1, dictionary3["key6"]?.count) + XCTAssertEqual(42, dictionary3["key6"]?[0] as? Int) + } +} diff --git a/AEPMessaging/Tests/UnitTests/Event+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/Event+MessagingTests.swift index e37196f7..b5efed9e 100644 --- a/AEPMessaging/Tests/UnitTests/Event+MessagingTests.swift +++ b/AEPMessaging/Tests/UnitTests/Event+MessagingTests.swift @@ -15,6 +15,7 @@ import XCTest @testable import AEPCore @testable import AEPMessaging @testable import AEPServices +import AEPTestUtils class EventPlusMessagingTests: XCTestCase { var messaging: Messaging! @@ -51,15 +52,19 @@ class EventPlusMessagingTests: XCTestCase { // MARK: - Helpers /// Gets an event to use for simulating a rules consequence - private func getRulesResponseEvent(type: String? = MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, + private func getRulesResponseEvent(type: String? = MessagingConstants.ConsequenceTypes.SCHEMA, triggeredConsequence: [String: Any]? = nil, removeDetails: [String]? = nil) -> Event { // details are the same for postback and pii, different for open url - var details = type == MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE ? [ - MessagingConstants.Event.Data.Key.IAM.TEMPLATE: MessagingConstants.Event.Data.Values.IAM.FULLSCREEN, - MessagingConstants.Event.Data.Key.IAM.HTML: testHtml, - MessagingConstants.Event.Data.Key.IAM.REMOTE_ASSETS: testAssets + var details: [String: Any] = type == MessagingConstants.ConsequenceTypes.SCHEMA ? [ + MessagingConstants.Event.Data.Key.ID: mockMessagingId, + MessagingConstants.Event.Data.Key.SCHEMA: MessagingConstants.PersonalizationSchemas.IN_APP, + MessagingConstants.Event.Data.Key.DATA: [ + "contentType": MessagingConstants.ContentTypes.TEXT_HTML, + "content": testHtml, + "remoteAssets": testAssets + ] ] : [:] if let keysToBeRemoved = removeDetails { @@ -171,7 +176,7 @@ class EventPlusMessagingTests: XCTestCase { return rulesEvent } - private func getRefreshMessagesEvent(type: String = MessagingConstants.Event.EventType.messaging, + private func getRefreshMessagesEvent(type: String = EventType.messaging, source: String = EventSource.requestContent, data: [String: Any]? = nil) -> Event { var eventData = data @@ -190,7 +195,7 @@ class EventPlusMessagingTests: XCTestCase { private func getClickthroughEvent(_ data: [String: Any]? = nil) -> Event { let data = data ?? [ MessagingConstants.Event.Data.Key.EVENT_TYPE: mockXdmEventType, - MessagingConstants.Event.Data.Key.MESSAGE_ID: mockMessagingId, + MessagingConstants.Event.Data.Key.ID: mockMessagingId, MessagingConstants.Event.Data.Key.ACTION_ID: mockActionId, MessagingConstants.Event.Data.Key.APPLICATION_OPENED: mockApplicationOpened, MessagingConstants.XDM.Key.ADOBE_XDM: [ @@ -199,7 +204,7 @@ class EventPlusMessagingTests: XCTestCase { ] ] - return Event(name: "Test Push clickthrough event", type: MessagingConstants.Event.EventType.messaging, + return Event(name: "Test Push clickthrough event", type: EventType.messaging, source: EventSource.requestContent, data: data) } @@ -220,259 +225,31 @@ class EventPlusMessagingTests: XCTestCase { return Event(name: "Push tracking status event", type: EventType.messaging, source: EventSource.responseContent, data: data) } - // MARK: - Testing Happy Path - - func testInAppMessageConsequenceType() throws { - // setup - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE) - - // verify - XCTAssertTrue(event.isInAppMessage) - } - - func testInAppMessageMessageId() throws { - // setup - let event = getRulesResponseEvent() - - // verify - XCTAssertEqual("552", event.messageId!) - } - - func testInAppMessageTemplate() throws { - // setup - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE) - - // verify - XCTAssertEqual(MessagingConstants.Event.Data.Values.IAM.FULLSCREEN, event.template!) - } - - func testInAppMessageHtml() throws { - // setup - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE) - - // verify - XCTAssertEqual(testHtml, event.html!) - } - - func testInAppMessageAssets() throws { - // setup - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE) - - // verify - XCTAssertEqual(2, event.remoteAssets!.count) - XCTAssertEqual(testAssets[0], event.remoteAssets![0]) - XCTAssertEqual(testAssets[1], event.remoteAssets![1]) - } - - // MARK: - Test mobileParameters - - func testGetMessageSettingsHappy() throws { - // setup - let event = TestableMobileParameters.getMobileParametersEvent() - - // test - let settings = event.getMessageSettings(withParent: self) - - // verify - XCTAssertNotNil(settings) - XCTAssertTrue(settings.parent is EventPlusMessagingTests) - XCTAssertEqual(TestableMobileParameters.mockWidth, settings.width) - XCTAssertEqual(TestableMobileParameters.mockHeight, settings.height) - XCTAssertEqual(MessageAlignment.fromString(TestableMobileParameters.mockVAlign), settings.verticalAlign) - XCTAssertEqual(TestableMobileParameters.mockVInset, settings.verticalInset) - XCTAssertEqual(MessageAlignment.fromString(TestableMobileParameters.mockHAlign), settings.horizontalAlign) - XCTAssertEqual(TestableMobileParameters.mockHInset, settings.horizontalInset) - XCTAssertEqual(TestableMobileParameters.mockUiTakeover, settings.uiTakeover) - XCTAssertEqual(UIColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 0), settings.getBackgroundColor(opacity: 0)) - XCTAssertEqual(CGFloat(TestableMobileParameters.mockCornerRadius), settings.cornerRadius) - XCTAssertEqual(MessageAnimation.fromString(TestableMobileParameters.mockDisplayAnimation), settings.displayAnimation) - XCTAssertEqual(MessageAnimation.fromString(TestableMobileParameters.mockDismissAnimation), settings.dismissAnimation) - XCTAssertNotNil(settings.gestures) - XCTAssertEqual(1, settings.gestures?.count) - XCTAssertEqual(URL(string: "adbinapp://dismiss")!.absoluteString, (settings.gestures![.swipeDown]!).absoluteString) - } - - func testGetMessageSettingsNoParent() throws { - // setup - let event = TestableMobileParameters.getMobileParametersEvent() - - // test - let settings = event.getMessageSettings(withParent: nil) - - // verify - XCTAssertNotNil(settings) - XCTAssertNil(settings.parent) - XCTAssertEqual(TestableMobileParameters.mockWidth, settings.width) - XCTAssertEqual(TestableMobileParameters.mockHeight, settings.height) - XCTAssertEqual(MessageAlignment.fromString(TestableMobileParameters.mockVAlign), settings.verticalAlign) - XCTAssertEqual(TestableMobileParameters.mockVInset, settings.verticalInset) - XCTAssertEqual(MessageAlignment.fromString(TestableMobileParameters.mockHAlign), settings.horizontalAlign) - XCTAssertEqual(TestableMobileParameters.mockHInset, settings.horizontalInset) - XCTAssertEqual(TestableMobileParameters.mockUiTakeover, settings.uiTakeover) - XCTAssertEqual(UIColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 0), settings.getBackgroundColor(opacity: 0)) - XCTAssertEqual(CGFloat(TestableMobileParameters.mockCornerRadius), settings.cornerRadius) - XCTAssertEqual(MessageAnimation.fromString(TestableMobileParameters.mockDisplayAnimation), settings.displayAnimation) - XCTAssertEqual(MessageAnimation.fromString(TestableMobileParameters.mockDismissAnimation), settings.dismissAnimation) - XCTAssertNotNil(settings.gestures) - XCTAssertEqual(1, settings.gestures?.count) - XCTAssertEqual(URL(string: "adbinapp://dismiss")!.absoluteString, (settings.gestures![.swipeDown]!).absoluteString) - } - - func testGetMessageSettingsMobileParametersEmpty() throws { - // setup - let event = getRefreshMessagesEvent() - - // test - let settings = event.getMessageSettings(withParent: self) - - // verify - XCTAssertNotNil(settings) - XCTAssertTrue(settings.parent is EventPlusMessagingTests) - XCTAssertNil(settings.width) - XCTAssertNil(settings.height) - XCTAssertEqual(.center, settings.verticalAlign) - XCTAssertNil(settings.verticalInset) - XCTAssertEqual(.center, settings.horizontalAlign) - XCTAssertNil(settings.horizontalInset) - XCTAssertTrue(settings.uiTakeover!) - XCTAssertEqual(UIColor(red: 1, green: 1, blue: 1, alpha: 0), settings.getBackgroundColor(opacity: 0)) - XCTAssertNil(settings.cornerRadius) - XCTAssertEqual(.none, settings.displayAnimation!) - XCTAssertEqual(.none, settings.dismissAnimation!) - XCTAssertNil(settings.gestures) - } - - func testGetMessageSettingsEmptyGestures() throws { - // setup - let params: [String: Any] = [ - MessagingConstants.Event.Data.Key.TRIGGERED_CONSEQUENCE: [ - MessagingConstants.Event.Data.Key.DETAIL: [ - MessagingConstants.Event.Data.Key.IAM.MOBILE_PARAMETERS: [ - MessagingConstants.Event.Data.Key.IAM.GESTURES: [:] - ] - ] - ] - ] - let event = TestableMobileParameters.getMobileParametersEvent(withData: params) - - // test - let settings = event.getMessageSettings(withParent: self) - - // verify - XCTAssertNotNil(settings) - XCTAssertNil(settings.gestures) - } - - // MARK: - Testing Message Object Validation - - func testInAppMessageObjectValidation() throws { - // setup - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE) - - // verify - XCTAssertTrue(event.containsValidInAppMessage) - } - - func testContainsValidInAppMessageNotIAMEvent() throws { - // setup - let event = Event(name: "not iam", type: "type", source: "source", data: nil) - - // verify - XCTAssertFalse(event.containsValidInAppMessage) - } - // MARK: - Testing Invalid Events - func testWrongConsequenceType() throws { + func testIsSchemaConsequenceWrongConsequenceType() throws { // setup let triggeredConsequence: [String: Any] = [ MessagingConstants.Event.Data.Key.TYPE: "Invalid", MessagingConstants.Event.Data.Key.ID: UUID().uuidString, - MessagingConstants.Event.Data.Key.DETAIL: [:] + MessagingConstants.Event.Data.Key.DETAIL: [:] as [String: Any] ] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, triggeredConsequence: triggeredConsequence) + let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.SCHEMA, triggeredConsequence: triggeredConsequence) // verify - XCTAssertFalse(event.isInAppMessage) - XCTAssertNil(event.template) - XCTAssertNil(event.html) - XCTAssertNil(event.remoteAssets) + XCTAssertFalse(event.isSchemaConsequence) } - func testNoConsequenceType() throws { + func testIsSchemaConsequenceNoConsequenceType() throws { // setup let triggeredConsequence: [String: Any] = [ MessagingConstants.Event.Data.Key.ID: UUID().uuidString, - MessagingConstants.Event.Data.Key.DETAIL: [:] + MessagingConstants.Event.Data.Key.DETAIL: [:] as [String: Any] ] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, triggeredConsequence: triggeredConsequence) + let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.SCHEMA, triggeredConsequence: triggeredConsequence) // verify - XCTAssertFalse(event.isInAppMessage) - XCTAssertNil(event.template) - XCTAssertNil(event.html) - XCTAssertNil(event.remoteAssets) - } - - func testMissingValuesInDetails() throws { - // setup - let triggeredConsequence: [String: Any] = [ - MessagingConstants.Event.Data.Key.TYPE: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, - MessagingConstants.Event.Data.Key.ID: UUID().uuidString, - MessagingConstants.Event.Data.Key.DETAIL: ["uninteresting": "data"] - ] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, triggeredConsequence: triggeredConsequence) - - // verify - XCTAssertTrue(event.isInAppMessage) - XCTAssertNil(event.template) - XCTAssertNil(event.html) - XCTAssertNil(event.remoteAssets) - } - - func testNoDetails() throws { - // setup - let triggeredConsequence: [String: Any] = [ - MessagingConstants.Event.Data.Key.TYPE: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, - MessagingConstants.Event.Data.Key.ID: UUID().uuidString - ] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, triggeredConsequence: triggeredConsequence) - - // verify - XCTAssertTrue(event.isInAppMessage) - XCTAssertNil(event.template) - XCTAssertNil(event.html) - XCTAssertNil(event.remoteAssets) - } - - func testInAppMessageObjectValidationNoRemoteAssets() throws { - // setup - let keysToRemove = [MessagingConstants.Event.Data.Key.IAM.REMOTE_ASSETS] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, removeDetails: keysToRemove) - - // verify - XCTAssertNil(event.remoteAssets) - XCTAssertTrue(event.containsValidInAppMessage, "remoteAssets is not a required field") - } - - func testInAppMessageObjectValidationNoTemplate() throws { - // setup - let keysToRemove = [MessagingConstants.Event.Data.Key.IAM.TEMPLATE] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, removeDetails: keysToRemove) - - // verify - XCTAssertNil(event.template) - XCTAssertTrue(event.containsValidInAppMessage, "template is not a required field") - } - - func testInAppMessageObjectValidationNoHtml() throws { - // setup - let keysToRemove = [MessagingConstants.Event.Data.Key.IAM.HTML] - let event = getRulesResponseEvent(type: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, removeDetails: keysToRemove) - - // verify - XCTAssertNil(event.html) - XCTAssertFalse(event.containsValidInAppMessage, "html is a required field") + XCTAssertFalse(event.isSchemaConsequence) } // MARK: - AEP Response Event Handling @@ -526,29 +303,25 @@ class EventPlusMessagingTests: XCTestCase { let p1 = event.payload?[0] XCTAssertNotNil(p1) - XCTAssertEqual(mockPayloadId1, p1?.propositionInfo.id) - XCTAssertEqual(mockAppSurface, p1?.propositionInfo.scope) - let scopeDetails1 = p1?.propositionInfo.scopeDetails + XCTAssertEqual(mockPayloadId1, p1?.uniqueId) + XCTAssertEqual(mockAppSurface, p1?.scope) + let scopeDetails1 = p1?.scopeDetails XCTAssertNotNil(scopeDetails1) XCTAssertEqual(1, scopeDetails1?.count) let item1 = p1?.items.first XCTAssertNotNil(item1) - let item1data = item1!.data - XCTAssertNotNil(item1data) - XCTAssertEqual(mockContent1, item1data.content) + XCTAssertEqual(mockContent1, item1?.itemData["content"] as? String) let p2 = event.payload?[1] XCTAssertNotNil(p2) - XCTAssertEqual(mockPayloadId2, p2?.propositionInfo.id) - XCTAssertEqual(mockAppSurface, p2?.propositionInfo.scope) - let scopeDetails2 = p2?.propositionInfo.scopeDetails + XCTAssertEqual(mockPayloadId2, p2?.uniqueId) + XCTAssertEqual(mockAppSurface, p2?.scope) + let scopeDetails2 = p2?.scopeDetails XCTAssertNotNil(scopeDetails2) XCTAssertEqual(1, scopeDetails2?.count) let item2 = p2?.items.first XCTAssertNotNil(item2) - let item2data = item2!.data - XCTAssertNotNil(item2data) - XCTAssertEqual(mockContent2, item2data.content) + XCTAssertEqual(mockContent2, item2?.itemData["content"] as? String) } func testPayloadIsNil() throws { @@ -738,6 +511,114 @@ class EventPlusMessagingTests: XCTestCase { XCTAssertNil(event.token) } + // MARK: - update propositions api events + + func testIsUpdatePropositionsEvent() throws { + // setup + let event = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["updatepropositions": true]) + let event2 = Event(name: "s", type: EventType.rulesEngine, source: EventSource.requestContent, data: ["updatepropositions": true]) + let event3 = Event(name: "s", type: EventType.messaging, source: EventSource.requestIdentity, data: ["updatepropositions": true]) + let event4 = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["nope": true]) + let event5 = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["updatepropositions": false]) + + // verify + XCTAssertTrue(event.isUpdatePropositionsEvent) + XCTAssertFalse(event2.isUpdatePropositionsEvent) + XCTAssertFalse(event3.isUpdatePropositionsEvent) + XCTAssertFalse(event4.isUpdatePropositionsEvent) + XCTAssertFalse(event5.isUpdatePropositionsEvent) + } + + func testSurfaces() throws { + // setup + let event = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["surfaces": [ + [ "uri": "https://blah" ], + [ "uri": "https://otherBlah/somepath/yay" ] + ]]) + + // verify + let result = event.surfaces + XCTAssertEqual(2, result?.count) + let first = result?.first + XCTAssertEqual("https://blah", first?.uri) + let second = result?[1] + XCTAssertEqual("https://otherBlah/somepath/yay", second?.uri) + } + + func testSurfacesNoSurfaces() throws { + // setup + let event = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: [:]) + + // verify + XCTAssertNil(event.surfaces) + } + + // MARK: - get propositions api events + + func testIsGetPropositionsEvent() throws { + // setup + let event = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["getpropositions": true]) + let event2 = Event(name: "s", type: EventType.rulesEngine, source: EventSource.requestContent, data: ["getpropositions": true]) + let event3 = Event(name: "s", type: EventType.messaging, source: EventSource.requestIdentity, data: ["getpropositions": true]) + let event4 = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["nope": true]) + let event5 = Event(name: "s", type: EventType.messaging, source: EventSource.requestContent, data: ["getpropositions": false]) + + // verify + XCTAssertTrue(event.isGetPropositionsEvent) + XCTAssertFalse(event2.isGetPropositionsEvent) + XCTAssertFalse(event3.isGetPropositionsEvent) + XCTAssertFalse(event4.isGetPropositionsEvent) + XCTAssertFalse(event5.isGetPropositionsEvent) + } + + func testPropositions() throws { + // setup + let propositionJson = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2") + let event = Event(name: "name", type: "type", source: "source", data: ["propositions": [ propositionJson ]]) + + // verify + XCTAssertNotNil(event.propositions) + XCTAssertEqual(1, event.propositions?.count) + } + + func testPropositionsBUTTHEREARENONE() throws { + // setup + let propositionJson = JSONFileLoader.getRulesJsonFromFile("inappPropositionV1") + let event = Event(name: "name", type: "type", source: "source", data: ["THESEARENOTpropositions": [ propositionJson ]]) + + // verify + XCTAssertNil(event.propositions) + } + + func testResponseError() throws { + // setup + let event = Event(name: "name", type: "type", source: "source", data: ["responseerror": 1 ]) + let event2 = Event(name: "name", type: "type", source: "source", data: ["nothing": 1 ]) + + // verify + XCTAssertNotNil(event.responseError) + XCTAssertEqual(event.responseError, .callbackTimeout) + XCTAssertNil(event2.responseError) + } + + // MARK: - error response event + + func testCreateErrorResponseEvent() throws { + // setup + let event = getClickthroughEvent() + + // test + let responseEvent = event.createErrorResponseEvent(.invalidResponse) + + // verify + XCTAssertEqual("Message propositions response", responseEvent.name) + XCTAssertEqual(EventType.messaging, responseEvent.type) + XCTAssertEqual(EventSource.responseContent, responseEvent.source) + XCTAssertEqual(1, responseEvent.data?.count) + let error = AEPError(rawValue: responseEvent.data?["responseerror"] as? Int ?? 0) + XCTAssertEqual(error, .invalidResponse) + } + func testGetPushTrackingStatus() throws { // verify XCTAssertEqual(PushTrackingStatus.trackingInitiated, getPushTrackingStatusEvent(status: .trackingInitiated).pushTrackingStatus) diff --git a/AEPMessaging/Tests/UnitTests/FeedItemTests.swift b/AEPMessaging/Tests/UnitTests/FeedItemTests.swift new file mode 100644 index 00000000..33237801 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/FeedItemTests.swift @@ -0,0 +1,230 @@ +/* + Copyright 2023 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices + +class FeedItemTests: XCTestCase { + let mockTitle = "mockTitle" + let mockBody = "mockBody" + let mockImageUrl = "mockImageUrl" + let mockActionUrl = "mockActionUrl" + let mockActionTitle = "mockActionTitle" + + override func setUp() { + + } + + // MARK: - Helpers + func dictionariesAreEqual (_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool { + if let l = lhs, let r = rhs { + return NSDictionary(dictionary: l).isEqual(to: r) + } + return lhs == nil && rhs == nil + } + + // MARK: - Happy path + + func testIsDecodable() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "body": "\(mockBody)", + "imageUrl": "\(mockImageUrl)", + "actionUrl": "\(mockActionUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + guard let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) else { + XCTFail("unable to decode FeedItem JSON") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockTitle, feedItem.title) + XCTAssertEqual(mockBody, feedItem.body) + XCTAssertEqual(mockImageUrl, feedItem.imageUrl) + XCTAssertEqual(mockActionUrl, feedItem.actionUrl) + XCTAssertEqual(mockActionTitle, feedItem.actionTitle) + } + + func testIsEncodable() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "body": "\(mockBody)", + "imageUrl": "\(mockImageUrl)", + "actionUrl": "\(mockActionUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + guard let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) else { + XCTFail("unable to decode FeedItem JSON") + return + } + + let encoder = JSONEncoder() + + // test + guard let encodedFeedItem = try? encoder.encode(feedItem) else { + XCTFail("unable to encode the FeedItem") + return + } + + // verify + guard let encodedFeedItemString = String(data: encodedFeedItem, encoding: .utf8) else { + XCTFail("unable to encode the FeedItem") + return + } + XCTAssertTrue(encodedFeedItemString.contains("\"title\":\"\(mockTitle)\"")) + XCTAssertTrue(encodedFeedItemString.contains("\"body\":\"\(mockBody)\"")) + XCTAssertTrue(encodedFeedItemString.contains("\"imageUrl\":\"\(mockImageUrl)\"")) + XCTAssertTrue(encodedFeedItemString.contains("\"actionUrl\":\"\(mockActionUrl)\"")) + XCTAssertTrue(encodedFeedItemString.contains("\"actionTitle\":\"\(mockActionTitle)\"")) + } + + + + // MARK: - test required properties + func testTitleIsRequired() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "body": "\(mockBody)", + "imageUrl": "\(mockImageUrl)", + "actionUrl": "\(mockActionUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) + + // verify + XCTAssertNil(feedItem) + } + + func testBodyIsRequired() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "imageUrl": "\(mockImageUrl)", + "actionUrl": "\(mockActionUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) + + // verify + XCTAssertNil(feedItem) + } + + // MARK: - test optional properties + + func testImageUrlIsNotRequired() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "body": "\(mockBody)", + "actionUrl": "\(mockActionUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + guard let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) else { + XCTFail("unable to decode FeedItem JSON") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockTitle, feedItem.title) + XCTAssertEqual(mockBody, feedItem.body) + XCTAssertNil(feedItem.imageUrl) + XCTAssertEqual(mockActionUrl, feedItem.actionUrl) + XCTAssertEqual(mockActionTitle, feedItem.actionTitle) + } + + func testActionUrlIsNotRequired() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "body": "\(mockBody)", + "imageUrl": "\(mockImageUrl)", + "actionTitle": "\(mockActionTitle)" +} +""".data(using: .utf8)! + + // test + guard let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) else { + XCTFail("unable to decode FeedItem JSON") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockTitle, feedItem.title) + XCTAssertEqual(mockBody, feedItem.body) + XCTAssertEqual(mockImageUrl, feedItem.imageUrl) + XCTAssertNil(feedItem.actionUrl) + XCTAssertEqual(mockActionTitle, feedItem.actionTitle) + } + + func testActionTitleIsNotRequired() throws { + // setup + let decoder = JSONDecoder() + let feedItemData = """ +{ + "title": "\(mockTitle)", + "body": "\(mockBody)", + "imageUrl": "\(mockImageUrl)", + "actionUrl": "\(mockActionUrl)" +} +""".data(using: .utf8)! + + // test + guard let feedItem = try? decoder.decode(FeedItem.self, from: feedItemData) else { + XCTFail("unable to decode FeedItem JSON") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockTitle, feedItem.title) + XCTAssertEqual(mockBody, feedItem.body) + XCTAssertEqual(mockImageUrl, feedItem.imageUrl) + XCTAssertEqual(mockActionUrl, feedItem.actionUrl) + XCTAssertNil(feedItem.actionTitle) + } +} diff --git a/AEPMessaging/Tests/TestHelpers/EventHub+Testable.swift b/AEPMessaging/Tests/UnitTests/FeedRulesEngineTests.swift similarity index 73% rename from AEPMessaging/Tests/TestHelpers/EventHub+Testable.swift rename to AEPMessaging/Tests/UnitTests/FeedRulesEngineTests.swift index 86ceb1a5..eadbcedd 100644 --- a/AEPMessaging/Tests/TestHelpers/EventHub+Testable.swift +++ b/AEPMessaging/Tests/UnitTests/FeedRulesEngineTests.swift @@ -1,5 +1,5 @@ /* - Copyright 2021 Adobe. All rights reserved. + 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 @@ -10,11 +10,16 @@ governing permissions and limitations under the License. */ -@testable import AEPCore import Foundation +import XCTest -extension EventHub { - static func reset() { - shared = EventHub() +@testable import AEPMessaging +import AEPServices + +class FeedRulesEngineTests: XCTestCase { + + override func setUp() { + } + } diff --git a/AEPMessaging/Tests/UnitTests/Dictionary+MergingTests.swift b/AEPMessaging/Tests/UnitTests/FeedTests.swift similarity index 50% rename from AEPMessaging/Tests/UnitTests/Dictionary+MergingTests.swift rename to AEPMessaging/Tests/UnitTests/FeedTests.swift index cc4520fe..4750dc19 100644 --- a/AEPMessaging/Tests/UnitTests/Dictionary+MergingTests.swift +++ b/AEPMessaging/Tests/UnitTests/FeedTests.swift @@ -1,5 +1,5 @@ /* - Copyright 2021 Adobe. All rights reserved. + Copyright 2023 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 @@ -14,28 +14,22 @@ import Foundation import XCTest @testable import AEPMessaging +import AEPServices -class DictionaryMergingTests: XCTestCase { - var dictionary1: [String: Any] = [ - "key": "value", - "key2": "value2", - "number": 1 - ] - - var dictionary2: [String: Any] = [ - "key": "newValue", - "key3": "value3", - "number": 552 - ] - - func testMerge() throws { +class FeedTests: XCTestCase { + func testFeedIsCreatable() throws { + // setup + let mockName = "aName" + let mockSurface = Surface(uri: "mySurface") + let mockFeedItem = FeedItemSchemaData.getEmpty() + // test - dictionary1.mergeXdm(rhs: dictionary2) - + let feed = Feed(name: mockName, surface: mockSurface, items: [mockFeedItem]) + // verify - XCTAssertEqual("newValue", dictionary1["key"] as? String) - XCTAssertEqual("value2", dictionary1["key2"] as? String) - XCTAssertEqual("value3", dictionary1["key3"] as? String) - XCTAssertEqual(552, dictionary1["number"] as? Int) + XCTAssertEqual(mockName, feed.name) + XCTAssertEqual(mockSurface, feed.surface) + XCTAssertEqual(1, feed.items.count) + XCTAssertEqual(mockFeedItem, feed.items.first) } } diff --git a/AEPMessaging/Tests/UnitTests/FullscreenMessage+MessageTests.swift b/AEPMessaging/Tests/UnitTests/FullscreenMessage+MessageTests.swift index ab43c9eb..62fe91c4 100644 --- a/AEPMessaging/Tests/UnitTests/FullscreenMessage+MessageTests.swift +++ b/AEPMessaging/Tests/UnitTests/FullscreenMessage+MessageTests.swift @@ -16,11 +16,12 @@ import XCTest import AEPCore @testable import AEPMessaging import AEPServices +import AEPTestUtils class FullscreenMessageMessageTests: XCTestCase { func testFullscreenMessageParentExtension() throws { - // setup - let message = Message(parent: Messaging(runtime: TestableExtensionRuntime())!, event: Event(name: "name", type: "type", source: "source", data: nil)) + // setup + let message = MockMessage(parent: Messaging(runtime: TestableExtensionRuntime())!, triggeringEvent: Event(name: "name", type: "type", source: "source", data: nil)) let messageSettings = MessageSettings(parent: message) let fullscreenMessage = ServiceProvider.shared.uiService.createFullscreenMessage?(payload: "", listener: nil, isLocalImageUsed: false, settings: messageSettings) as! FullscreenMessage diff --git a/AEPMessaging/Tests/UnitTests/ItemDataTests.swift b/AEPMessaging/Tests/UnitTests/ItemDataTests.swift deleted file mode 100644 index 18d8d44c..00000000 --- a/AEPMessaging/Tests/UnitTests/ItemDataTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -/* - Copyright 2022 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 Foundation -import XCTest - -@testable import AEPMessaging - -class ItemDataTests: XCTestCase { - // MARK: - Happy path - func testIsConstructable() throws { - // setup - let itemData = ItemData(id: "abcd", content: "efgh") - - // verify - XCTAssertNotNil(itemData) - XCTAssertEqual("abcd", itemData.id) - XCTAssertEqual("efgh", itemData.content) - } - - func testIsEncodable() throws { - // setup - let encoder = JSONEncoder() - let itemData = ItemData(id: "abcd", content: "efgh") - - // test - guard let encodedItemData = try? encoder.encode(itemData) else { - XCTFail("unable to encode ItemData") - return - } - - // verify - XCTAssertEqual("{\"id\":\"abcd\",\"content\":\"efgh\"}", String(data: encodedItemData, encoding: .utf8)) - } - - func testIsDecodable() throws { - // setup - let decoder = JSONDecoder() - let itemData = "{\"id\":\"abcd\", \"content\": \"efgh\"}".data(using: .utf8)! - - // test - guard let decodedItemData = try? decoder.decode(ItemData.self, from: itemData) else { - XCTFail("unable to decode ItemData json") - return - } - - // verify - XCTAssertEqual("abcd", decodedItemData.id) - XCTAssertEqual("efgh", decodedItemData.content) - } - - // MARK: - Exception path - func testIdIsOptional() throws { - // setup - let decoder = JSONDecoder() - let itemData = "{\"content\": \"efgh\"}".data(using: .utf8)! - - // test - guard let decodedItemData = try? decoder.decode(ItemData.self, from: itemData) else { - XCTFail("unable to decode ItemData json") - return - } - - // verify - XCTAssertNil(decodedItemData.id) - XCTAssertEqual("efgh", decodedItemData.content) - } - - func testContentIsRequired() throws { - // setup - let decoder = JSONDecoder() - let itemData = "{\"id\": \"abcd\"}".data(using: .utf8)! - - // test - let decodedItemData = try? decoder.decode(ItemData.self, from: itemData) - - // verify - XCTAssertNil(decodedItemData) - } -} diff --git a/AEPMessaging/Tests/UnitTests/Message+FullscreenMessageDelegateTests.swift b/AEPMessaging/Tests/UnitTests/Message+FullscreenMessageDelegateTests.swift index 69562f5e..d8e27dbd 100644 --- a/AEPMessaging/Tests/UnitTests/Message+FullscreenMessageDelegateTests.swift +++ b/AEPMessaging/Tests/UnitTests/Message+FullscreenMessageDelegateTests.swift @@ -16,6 +16,7 @@ import XCTest import AEPCore @testable import AEPMessaging import AEPServices +import AEPTestUtils class MessageFullscreenMessageDelegateTests: XCTestCase { var message: Message! @@ -24,6 +25,7 @@ class MessageFullscreenMessageDelegateTests: XCTestCase { var mockEvent: Event! var mockFullscreenMessage: MockFullscreenMessage! var mockMessage: MockMessage! + var mockPropositionItem: PropositionItem! let invalidUrlString = "-.98.3/~@!# oopsnotaurllol" let genericUrlString = "https://www.adobe.com/" let inAppUrlString = "adbinapp://dismiss?interaction=testing&link=https://www.adobe.com/" @@ -33,10 +35,13 @@ class MessageFullscreenMessageDelegateTests: XCTestCase { let animationOverrideEmptyUrlString = "adbinapp://dismiss?animate=" override func setUp() { + let mockPropositionItemData = JSONFileLoader.getRulesJsonFromFile("mockPropositionItem") + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockPropositionItemData) + mockMessaging = MockMessaging(runtime: mockRuntime) mockEvent = Event(name: "Message Test", type: "type", source: "source", data: nil) - message = Message(parent: mockMessaging, event: mockEvent) - mockMessage = MockMessage(parent: mockMessaging, event: mockEvent) + message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) + mockMessage = MockMessage(parent: mockMessaging, triggeringEvent: mockEvent) mockFullscreenMessage = MockFullscreenMessage(parent: mockMessage) mockMessage.fullscreenMessage = mockFullscreenMessage } @@ -60,6 +65,14 @@ class MessageFullscreenMessageDelegateTests: XCTestCase { // verify XCTAssertFalse(mockMessage.dismissCalled) } + + func testOverrideUrlLoadNilUrlParam() throws { + // test + let result = message.overrideUrlLoad(message: mockFullscreenMessage, url: nil) + + // verify + XCTAssertTrue(result) + } func testOverrideUrlLoadGenericUrl() throws { // test @@ -85,7 +98,7 @@ class MessageFullscreenMessageDelegateTests: XCTestCase { XCTAssertFalse(result) XCTAssertTrue(mockMessage.trackCalled) XCTAssertEqual("testing", mockMessage.paramTrackInteraction) - XCTAssertEqual(.inappInteract, mockMessage.paramTrackEventType) + XCTAssertEqual(.interact, mockMessage.paramTrackEventType) XCTAssertTrue(mockMessage.dismissCalled) } diff --git a/AEPMessaging/Tests/UnitTests/MessageTests.swift b/AEPMessaging/Tests/UnitTests/MessageTests.swift index c0d51a54..1eb3cbf7 100644 --- a/AEPMessaging/Tests/UnitTests/MessageTests.swift +++ b/AEPMessaging/Tests/UnitTests/MessageTests.swift @@ -16,25 +16,31 @@ import XCTest @testable import AEPCore @testable import AEPMessaging @testable import AEPServices +import AEPTestUtils import WebKit -class MessageTests: XCTestCase, FullscreenMessageDelegate { +class MessageTests: XCTestCase { let ASYNC_TIMEOUT = 5.0 var mockMessaging: MockMessaging! - var mockRuntime = TestableExtensionRuntime() + var mockRuntime: TestableExtensionRuntime! var mockEvent: Event! var mockEventData: [String: Any]? let mockAssetString = "https://blog.adobe.com/en/publish/2020/05/28/media_1cc0fcc19cf0e64decbceb3a606707a3ad23f51dd.png" let mockMessageId = "552" + var mockInAppItemData: [String: Any]! + var mockPropositionItem: PropositionItem! var mockPropositionInfo: PropositionInfo! let mockPropId = "1337" let mockPropScope = "mobileapp://com.apple.dt.xctest.tool" - let mockPropScopeDetails: [String: AnyCodable] = ["akey":"avalue"] + let mockPropScopeDetails: [String: AnyCodable] = ["activity":["id":"1337"]] var onShowExpectation: XCTestExpectation? var onDismissExpectation: XCTestExpectation? var handleJavascriptMessageExpectation: XCTestExpectation? - + override func setUp() { + mockInAppItemData = JSONFileLoader.getRulesJsonFromFile("mockPropositionItem") + + mockRuntime = TestableExtensionRuntime() mockMessaging = MockMessaging(runtime: mockRuntime) mockEventData = [ @@ -42,7 +48,7 @@ class MessageTests: XCTestCase, FullscreenMessageDelegate { MessagingConstants.Event.Data.Key.ID: mockMessageId, MessagingConstants.Event.Data.Key.DETAIL: [ MessagingConstants.Event.Data.Key.IAM.REMOTE_ASSETS: [mockAssetString], - MessagingConstants.Event.Data.Key.IAM.MOBILE_PARAMETERS: TestableMobileParameters.mobileParameters + "mobileParameters": TestableMobileParameters.mobileParameters ] ] ] @@ -51,79 +57,139 @@ class MessageTests: XCTestCase, FullscreenMessageDelegate { mockPropositionInfo = PropositionInfo(id: mockPropId, scope: mockPropScope, scopeDetails: mockPropScopeDetails) } - func testMessageInitHappy() throws { + func testCreateFromPropositionItemHappy() throws { // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) let cache = Cache(name: MessagingConstants.Caches.CACHE_NAME) try cache.set(key: mockAssetString, entry: CacheEntry(data: mockAssetString.data(using: .utf8)!, expiry: .never, metadata: nil)) // test - let message = Message(parent: mockMessaging, event: mockEvent) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } // verify XCTAssertEqual(mockMessaging, message.parent) XCTAssertEqual(mockEvent, message.triggeringEvent) - XCTAssertEqual(mockMessageId, message.id) + XCTAssertEqual("itemId", message.id) XCTAssertNotNil(message.fullscreenMessage) XCTAssertNotNil(message.assets) XCTAssertEqual(1, message.assets?.count) - XCTAssertEqual(true, message.fullscreenMessage?.isLocalImageUsed) // cleanup try cache.remove(key: mockAssetString) } + + func testCreateFromPropositionItemAssetNotInCache() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) - func testMessageInitUsingDefaultValues() throws { // test - mockEventData = [ - MessagingConstants.Event.Data.Key.TRIGGERED_CONSEQUENCE: [ - MessagingConstants.Event.Data.Key.DETAIL: [:] - ] - ] - mockEvent = Event(name: "Message Test", type: "type", source: "source", data: mockEventData) - let message = Message(parent: mockMessaging, event: mockEvent) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } // verify XCTAssertEqual(mockMessaging, message.parent) XCTAssertEqual(mockEvent, message.triggeringEvent) - XCTAssertEqual("", message.id) + XCTAssertEqual("itemId", message.id) XCTAssertNotNil(message.fullscreenMessage) - XCTAssertNil(message.assets) - XCTAssertEqual(false, message.fullscreenMessage?.isLocalImageUsed) + XCTAssertNotNil(message.assets) + XCTAssertEqual(0, message.assets?.count) } - func testPropositionInfo() throws { + func testCreateFromPropositionItemNoAssets() throws { // setup + mockInAppItemData?.removeValue(forKey: "remoteAssets") + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) let cache = Cache(name: MessagingConstants.Caches.CACHE_NAME) try cache.set(key: mockAssetString, entry: CacheEntry(data: mockAssetString.data(using: .utf8)!, expiry: .never, metadata: nil)) // test - let message = Message(parent: mockMessaging, event: mockEvent) - message.propositionInfo = mockPropositionInfo + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } // verify - XCTAssertEqual(mockPropositionInfo.id, message.propositionInfo?.id) - XCTAssertEqual(mockPropositionInfo.scope, message.propositionInfo?.scope) - XCTAssertEqual(mockPropositionInfo.scopeDetails, message.propositionInfo?.scopeDetails) + XCTAssertEqual(mockMessaging, message.parent) + XCTAssertEqual(mockEvent, message.triggeringEvent) + XCTAssertEqual("itemId", message.id) + XCTAssertNotNil(message.fullscreenMessage) + XCTAssertNil(message.assets) + + // cleanup + try cache.remove(key: mockAssetString) } + + func testCreateFromPropositionItemNotIamSchemaData() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .htmlContent, itemData: [:]) - func testShow() throws { + // test + let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) + + // verify + XCTAssertNil(message) + } + + func testWKWebViewIsGettable() throws { // setup - let message = Message(parent: mockMessaging, event: mockEvent) + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + onShowExpectation = XCTestExpectation(description: "onShow called") + + // test + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } message.fullscreenMessage?.listener = self + message.show() + wait(for: [onShowExpectation!], timeout: ASYNC_TIMEOUT) + + // test + let webView = message.view as? WKWebView + + // verify + XCTAssertNotNil(webView) + + // cleanup + message.dismiss() + } + + func testShow() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) onShowExpectation = XCTestExpectation(description: "onShow called") + + // test + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.fullscreenMessage?.listener = self // test message.show() // verify wait(for: [onShowExpectation!], timeout: ASYNC_TIMEOUT) + + // cleanup + message.dismiss() } - + func testDismiss() throws { // setup - let message = Message(parent: mockMessaging, event: mockEvent) - message.fullscreenMessage?.listener = self + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) onDismissExpectation = XCTestExpectation(description: "onDismiss called") + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.fullscreenMessage?.listener = self // onDismiss will not get called if the message isn't currently being shown onShowExpectation = XCTestExpectation(description: "onShow called") @@ -136,10 +202,61 @@ class MessageTests: XCTestCase, FullscreenMessageDelegate { // verify wait(for: [onDismissExpectation!], timeout: ASYNC_TIMEOUT) } + + func testTrackInteraction() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + + // test + message.track("mockInteraction", withEdgeEventType: .interact) + + // verify + XCTAssertEqual(1, mockMessaging.testableRuntime.dispatchedEvents.count) + } + + func testTrackDisplay() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + + // test + message.track(withEdgeEventType: .display) + + // verify + XCTAssertEqual(1, mockMessaging.testableRuntime.dispatchedEvents.count) + } + + func testTrackNoPropositionInfo() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + + // test + message.track("mockInteraction", withEdgeEventType: .interact) + + // verify + XCTAssertEqual(0, mockMessaging.testableRuntime.dispatchedEvents.count) + } func testHandleJavascriptMessage() throws { // setup - let message = Message(parent: mockMessaging, event: mockEvent) + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } let mockFullscreenMessage = MockFullscreenMessage(parent: message) mockFullscreenMessage.paramJavascriptHandlerReturnValue = "abc" message.fullscreenMessage = mockFullscreenMessage @@ -157,34 +274,137 @@ class MessageTests: XCTestCase, FullscreenMessageDelegate { XCTAssertEqual("test", mockFullscreenMessage.paramJavascriptMessage) } - func testViewAccess() throws { + func testTriggerableWithAutoTrack() throws { // setup - let message = Message(parent: mockMessaging, event: mockEvent) - let mockFullscreenMessage = MockFullscreenMessage(parent: message) - message.fullscreenMessage = mockFullscreenMessage + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + + // test + message.trigger() // verify - XCTAssertNotNil(message.view) - XCTAssertTrue(message.view is WKWebView) + XCTAssertEqual(2, mockMessaging.testableRuntime.dispatchedEvents.count) + let trackEvent = mockMessaging.testableRuntime.firstEvent + XCTAssertEqual("Messaging interaction event", trackEvent?.name) + let eventHistoryEvent = mockMessaging.testableRuntime.secondEvent + XCTAssertEqual("Write IAM event to history", eventHistoryEvent?.name) } + + func testTriggerableNoAutoTrack() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + message.autoTrack = false + + // test + message.trigger() - func testTriggerable() throws { + // verify + XCTAssertEqual(1, mockMessaging.testableRuntime.dispatchedEvents.count) + let eventHistoryEvent = mockMessaging.testableRuntime.firstEvent + XCTAssertEqual("Write IAM event to history", eventHistoryEvent?.name) + } + + func testRecordEventHistoryHappy() throws { // setup - let message = Message(parent: mockMessaging, event: mockEvent) + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + + // test + message.recordEventHistory(eventType: .display, interaction: "interaction") // verify - message.trigger() + XCTAssertEqual(1, mockMessaging.testableRuntime.dispatchedEvents.count) + let eventHistoryEvent = mockMessaging.testableRuntime.firstEvent + XCTAssertEqual(EventType.messaging, eventHistoryEvent?.type) + XCTAssertEqual(MessagingConstants.Event.Source.EVENT_HISTORY_WRITE, eventHistoryEvent?.source) + let eventData = eventHistoryEvent?.data + XCTAssertEqual(1, eventData?.count) + let iamMap = try XCTUnwrap(eventData?["iam"] as? [String: Any]) + XCTAssertEqual(3, iamMap.count) + let iamEventType = try XCTUnwrap(iamMap["eventType"] as? String) + XCTAssertEqual("display", iamEventType) + let iamMessageId = try XCTUnwrap(iamMap["id"] as? String) + XCTAssertEqual(mockPropId, iamMessageId) + let iamAction = try XCTUnwrap(iamMap["action"] as? String) + XCTAssertEqual("interaction", iamAction) + XCTAssertNotNil(eventHistoryEvent?.mask) + XCTAssertEqual(3, eventHistoryEvent?.mask?.count) + XCTAssertEqual("iam.eventType", eventHistoryEvent?.mask?[0]) + XCTAssertEqual("iam.id", eventHistoryEvent?.mask?[1]) + XCTAssertEqual("iam.action", eventHistoryEvent?.mask?[2]) } + + func testRecordEventHistoryNoPropInfo() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + + // test + message.recordEventHistory(eventType: .display, interaction: "interaction") - // MARK: - FullscreenMessageDelegate + // verify + XCTAssertEqual(0, mockMessaging.testableRuntime.dispatchedEvents.count) + } + + func testRecordEventHistoryNoInteraction() throws { + // setup + mockPropositionItem = PropositionItem(itemId: "itemId", schema: .inapp, itemData: mockInAppItemData) + guard let message = Message.fromPropositionItem(mockPropositionItem, with: mockMessaging, triggeringEvent: mockEvent) else { + XCTFail("failed to create message from convenience constructor.") + return + } + message.propositionInfo = mockPropositionInfo + + // test + message.recordEventHistory(eventType: .display, interaction: nil) + + // verify + XCTAssertEqual(1, mockMessaging.testableRuntime.dispatchedEvents.count) + let eventHistoryEvent = mockMessaging.testableRuntime.firstEvent + XCTAssertEqual(EventType.messaging, eventHistoryEvent?.type) + XCTAssertEqual(MessagingConstants.Event.Source.EVENT_HISTORY_WRITE, eventHistoryEvent?.source) + let eventData = eventHistoryEvent?.data + XCTAssertEqual(1, eventData?.count) + let iamMap = try XCTUnwrap(eventData?["iam"] as? [String: Any]) + XCTAssertEqual(3, iamMap.count) + let iamEventType = try XCTUnwrap(iamMap["eventType"] as? String) + XCTAssertEqual("display", iamEventType) + let iamMessageId = try XCTUnwrap(iamMap["id"] as? String) + XCTAssertEqual(mockPropId, iamMessageId) + let iamAction = try XCTUnwrap(iamMap["action"] as? String) + XCTAssertEqual("", iamAction) + XCTAssertNotNil(eventHistoryEvent?.mask) + XCTAssertEqual(3, eventHistoryEvent?.mask?.count) + XCTAssertEqual("iam.eventType", eventHistoryEvent?.mask?[0]) + XCTAssertEqual("iam.id", eventHistoryEvent?.mask?[1]) + XCTAssertEqual("iam.action", eventHistoryEvent?.mask?[2]) + } +} - public func onShow(message _: FullscreenMessage) { +extension MessageTests: FullscreenMessageDelegate { + public func onShow(message: FullscreenMessage) { onShowExpectation?.fulfill() } public func onShowFailure() {} - public func onDismiss(message _: FullscreenMessage) { + public func onDismiss(message: FullscreenMessage) { onDismissExpectation?.fulfill() } diff --git a/AEPMessaging/Tests/UnitTests/Messaging+EdgeEventsTests.swift b/AEPMessaging/Tests/UnitTests/Messaging+EdgeEventsTests.swift index c34bf158..a552817f 100644 --- a/AEPMessaging/Tests/UnitTests/Messaging+EdgeEventsTests.swift +++ b/AEPMessaging/Tests/UnitTests/Messaging+EdgeEventsTests.swift @@ -16,11 +16,13 @@ import XCTest import AEPCore @testable import AEPMessaging import AEPServices +import AEPTestUtils class MessagingEdgeEventsTests: XCTestCase { var mockRuntime: TestableExtensionRuntime! var messaging: Messaging! - var mockRulesEngine: MockMessagingRulesEngine! + var mockMessagingRulesEngine: MockMessagingRulesEngine! + var mockFeedRulesEngine: MockFeedRulesEngine! var mockLaunchRulesEngine: MockLaunchRulesEngine! var mockCache: MockCache! let mockIamSurface = "mobileapp://com.apple.dt.xctest.tool" @@ -36,8 +38,9 @@ class MessagingEdgeEventsTests: XCTestCase { mockRuntime = TestableExtensionRuntime() mockCache = MockCache(name: "mockCache") mockLaunchRulesEngine = MockLaunchRulesEngine(name: "mcokLaunchRulesEngine", extensionRuntime: mockRuntime) - mockRulesEngine = MockMessagingRulesEngine(extensionRuntime: mockRuntime, rulesEngine: mockLaunchRulesEngine, cache: mockCache) - messaging = Messaging(runtime: mockRuntime, rulesEngine: mockRulesEngine, expectedScope: mockIamSurface) + mockMessagingRulesEngine = MockMessagingRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngine, cache: mockCache) + mockFeedRulesEngine = MockFeedRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngine) + messaging = Messaging(runtime: mockRuntime, rulesEngine: mockMessagingRulesEngine, feedRulesEngine: mockFeedRulesEngine, expectedSurfaceUri: mockIamSurface, cache: mockCache) } // MARK: - helpers @@ -64,7 +67,7 @@ class MessagingEdgeEventsTests: XCTestCase { func getMessageTrackingEventData(addAdobeXdm: Bool? = true, addMixins: Bool? = false, addCjm: Bool? = true) -> [String: Any] { var data: [String: Any] = [:] data[MessagingConstants.Event.Data.Key.EVENT_TYPE] = "testEventType" - data[MessagingConstants.Event.Data.Key.MESSAGE_ID] = "testMessageId" + data[MessagingConstants.Event.Data.Key.ID] = "testMessageId" if addAdobeXdm! { var adobeXdmData: [String: Any] = [:] @@ -106,7 +109,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test messaging.handleTrackingInfo(event: event) @@ -144,7 +147,7 @@ class MessagingEdgeEventsTests: XCTestCase { func testGetPushPlatformNormal() throws { // setup setConfigSharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test let result = messaging.getPushPlatform(forEvent: event) @@ -159,7 +162,7 @@ class MessagingEdgeEventsTests: XCTestCase { MessagingConstants.SharedState.Configuration.EXPERIENCE_EVENT_DATASET: MOCK_EVENT_DATASET, MessagingConstants.SharedState.Configuration.USE_SANDBOX: true ]) - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test let result = messaging.getPushPlatform(forEvent: event) @@ -170,7 +173,7 @@ class MessagingEdgeEventsTests: XCTestCase { func testGetPushPlatformNoConfig() throws { // setup - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test let result = messaging.getPushPlatform(forEvent: event) @@ -184,7 +187,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData().merging([MessagingConstants.Event.Data.Key.ACTION_ID: "superActionId"]) { _, new in new } setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -207,7 +210,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState([:]) setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test messaging.handleTrackingInfo(event: event) @@ -224,7 +227,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState([MessagingConstants.SharedState.Configuration.EXPERIENCE_EVENT_DATASET: ""]) setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: getMessageTrackingEventData()) // test messaging.handleTrackingInfo(event: event) @@ -241,7 +244,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: [:]) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: [:]) // test messaging.handleTrackingInfo(event: event) @@ -258,7 +261,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: [MessagingConstants.Event.Data.Key.EVENT_TYPE: "testEventType"]) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: [MessagingConstants.Event.Data.Key.EVENT_TYPE: "testEventType"]) // test messaging.handleTrackingInfo(event: event) @@ -275,7 +278,7 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: [MessagingConstants.Event.Data.Key.EVENT_TYPE: "testEventType", MessagingConstants.Event.Data.Key.MESSAGE_ID: ""]) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: [MessagingConstants.Event.Data.Key.EVENT_TYPE: "testEventType", MessagingConstants.Event.Data.Key.ID: ""]) // test messaging.handleTrackingInfo(event: event) @@ -303,7 +306,7 @@ class MessagingEdgeEventsTests: XCTestCase { let pushDetails = pushDetailsArray?[0] XCTAssertEqual(5, pushDetails?.count) let appId = pushDetails?[MessagingConstants.XDM.Push.APP_ID] as? String - XCTAssertEqual("com.apple.dt.xctest.tool", appId) + XCTAssertEqual("com.adobe.ajo.e2eTestApp", appId) let token = pushDetails?[MessagingConstants.XDM.Push.TOKEN] as? String XCTAssertEqual(MOCK_PUSH_TOKEN, token) let platform = pushDetails?[MessagingConstants.XDM.Push.PLATFORM] as? String @@ -325,7 +328,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData() setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -358,7 +361,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData(addAdobeXdm: false) setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -378,7 +381,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData(addMixins: true) setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -399,7 +402,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData(addMixins: false, addCjm: false) setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -419,7 +422,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData().merging([MessagingConstants.Event.Data.Key.APPLICATION_OPENED: true]) { _, new in new } setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -440,7 +443,7 @@ class MessagingEdgeEventsTests: XCTestCase { let eventData = getMessageTrackingEventData().merging([MessagingConstants.Event.Data.Key.APPLICATION_OPENED: false]) { _, new in new } setConfigSharedState() setIdentitySharedState() - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) // test messaging.handleTrackingInfo(event: event) @@ -460,198 +463,203 @@ class MessagingEdgeEventsTests: XCTestCase { // setup setConfigSharedState() setIdentitySharedState() - let mockEvent = Event(name: "triggeringEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: nil) - let mockEdgeEventType = MessagingEdgeEventType.inappInteract + let mockEvent = Event(name: "triggeringEvent", type: EventType.messaging, source: EventSource.requestContent, data: nil) + let mockEdgeEventType = MessagingEdgeEventType.interact let mockInteraction = "swords" - let mockMessage = MockMessage(parent: messaging, event: mockEvent) + let mockMessage = MockMessage(parent: messaging, triggeringEvent: mockEvent) mockMessage.propositionInfo = PropositionInfo(id: "propId", scope: "propScope", scopeDetails: ["correlationID": "mockCorrelationID", "characteristics":["cjmEventToken":"abcd"]]) // test - messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) + messaging.sendPropositionInteraction(withXdm: [:]) +// messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) // verify - XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let dispatchedEvent = mockRuntime.firstEvent - // validate type and source - XCTAssertEqual(EventType.edge, dispatchedEvent?.type) - XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) - // validate event mask entries - XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) - XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) - XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) - // validate xdm map - let dispatchedEventData = dispatchedEvent?.data - let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] - XCTAssertEqual("decisioning.propositionInteract", dispatchedXdmMap?["eventType"] as? String) - let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] - let decisioningMap = experienceMap?["decisioning"] as? [String: Any] - let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] - XCTAssertEqual(1, propositionEventTypeMap?["interact"] as? Int) - let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] - XCTAssertEqual(2, propositionActionMap?.count) - XCTAssertEqual(mockInteraction, propositionActionMap?["id"] as? String) - XCTAssertEqual(mockInteraction, propositionActionMap?["label"] as? String) - let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] - XCTAssertEqual(1, propositionsArray?.count) - let prop = propositionsArray?.first! - XCTAssertEqual("propId", prop?["id"] as? String) - XCTAssertEqual("propScope", prop?["scope"] as? String) - let scopeDetails = prop?["scopeDetails"] as? [String: Any] - XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) - let characteristics = scopeDetails?["characteristics"] as? [String: Any] - XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let dispatchedEvent = mockRuntime.firstEvent +// // validate type and source +// XCTAssertEqual(EventType.edge, dispatchedEvent?.type) +// XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) +// // validate event mask entries +// XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) +// XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) +// XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) +// // validate xdm map +// let dispatchedEventData = dispatchedEvent?.data +// let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] +// XCTAssertEqual("decisioning.propositionInteract", dispatchedXdmMap?["eventType"] as? String) +// let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] +// let decisioningMap = experienceMap?["decisioning"] as? [String: Any] +// let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] +// XCTAssertEqual(1, propositionEventTypeMap?["interact"] as? Int) +// let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] +// XCTAssertEqual(2, propositionActionMap?.count) +// XCTAssertEqual(mockInteraction, propositionActionMap?["id"] as? String) +// XCTAssertEqual(mockInteraction, propositionActionMap?["label"] as? String) +// let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] +// XCTAssertEqual(1, propositionsArray?.count) +// let prop = propositionsArray?.first! +// XCTAssertEqual("propId", prop?["id"] as? String) +// XCTAssertEqual("propScope", prop?["scope"] as? String) +// let scopeDetails = prop?["scopeDetails"] as? [String: Any] +// XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) +// let characteristics = scopeDetails?["characteristics"] as? [String: Any] +// XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) } func testSendPropositionInteractionDisplay() throws { // setup setConfigSharedState() setIdentitySharedState() - let mockEvent = Event(name: "triggeringEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: nil) - let mockEdgeEventType = MessagingEdgeEventType.inappDisplay + let mockEvent = Event(name: "triggeringEvent", type: EventType.messaging, source: EventSource.requestContent, data: nil) + let mockEdgeEventType = MessagingEdgeEventType.display let mockInteraction = "swords" - let mockMessage = MockMessage(parent: messaging, event: mockEvent) + let mockMessage = MockMessage(parent: messaging, triggeringEvent: mockEvent) mockMessage.propositionInfo = PropositionInfo(id: "propId", scope: "propScope", scopeDetails: ["correlationID": "mockCorrelationID", "characteristics":["cjmEventToken":"abcd"]]) // test - messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) + messaging.sendPropositionInteraction(withXdm: [:]) +// messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) // verify - XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let dispatchedEvent = mockRuntime.firstEvent - // validate type and source - XCTAssertEqual(EventType.edge, dispatchedEvent?.type) - XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) - // validate event mask entries - XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) - XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) - XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) - // validate xdm map - let dispatchedEventData = dispatchedEvent?.data - let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] - XCTAssertEqual("decisioning.propositionDisplay", dispatchedXdmMap?["eventType"] as? String) - let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] - let decisioningMap = experienceMap?["decisioning"] as? [String: Any] - let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] - XCTAssertEqual(1, propositionEventTypeMap?["display"] as? Int) - let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] - XCTAssertNil(propositionActionMap) - let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] - XCTAssertEqual(1, propositionsArray?.count) - let prop = propositionsArray?.first! - XCTAssertEqual("propId", prop?["id"] as? String) - XCTAssertEqual("propScope", prop?["scope"] as? String) - let scopeDetails = prop?["scopeDetails"] as? [String: Any] - XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) - let characteristics = scopeDetails?["characteristics"] as? [String: Any] - XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let dispatchedEvent = mockRuntime.firstEvent +// // validate type and source +// XCTAssertEqual(EventType.edge, dispatchedEvent?.type) +// XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) +// // validate event mask entries +// XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) +// XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) +// XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) +// // validate xdm map +// let dispatchedEventData = dispatchedEvent?.data +// let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] +// XCTAssertEqual("decisioning.propositionDisplay", dispatchedXdmMap?["eventType"] as? String) +// let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] +// let decisioningMap = experienceMap?["decisioning"] as? [String: Any] +// let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] +// XCTAssertEqual(1, propositionEventTypeMap?["display"] as? Int) +// let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] +// XCTAssertNil(propositionActionMap) +// let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] +// XCTAssertEqual(1, propositionsArray?.count) +// let prop = propositionsArray?.first! +// XCTAssertEqual("propId", prop?["id"] as? String) +// XCTAssertEqual("propScope", prop?["scope"] as? String) +// let scopeDetails = prop?["scopeDetails"] as? [String: Any] +// XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) +// let characteristics = scopeDetails?["characteristics"] as? [String: Any] +// XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) } func testSendPropositionInteractionDismiss() throws { // setup setConfigSharedState() setIdentitySharedState() - let mockEvent = Event(name: "triggeringEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: nil) - let mockEdgeEventType = MessagingEdgeEventType.inappDismiss + let mockEvent = Event(name: "triggeringEvent", type: EventType.messaging, source: EventSource.requestContent, data: nil) + let mockEdgeEventType = MessagingEdgeEventType.dismiss let mockInteraction = "swords" - let mockMessage = MockMessage(parent: messaging, event: mockEvent) + let mockMessage = MockMessage(parent: messaging, triggeringEvent: mockEvent) mockMessage.propositionInfo = PropositionInfo(id: "propId", scope: "propScope", scopeDetails: ["correlationID": "mockCorrelationID", "characteristics":["cjmEventToken":"abcd"]]) // test - messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) + messaging.sendPropositionInteraction(withXdm: [:]) +// messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) // verify - XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let dispatchedEvent = mockRuntime.firstEvent - // validate type and source - XCTAssertEqual(EventType.edge, dispatchedEvent?.type) - XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) - // validate event mask entries - XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) - XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) - XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) - // validate xdm map - let dispatchedEventData = dispatchedEvent?.data - let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] - XCTAssertEqual("decisioning.propositionDismiss", dispatchedXdmMap?["eventType"] as? String) - let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] - let decisioningMap = experienceMap?["decisioning"] as? [String: Any] - let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] - XCTAssertEqual(1, propositionEventTypeMap?["dismiss"] as? Int) - let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] - XCTAssertNil(propositionActionMap) - let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] - XCTAssertEqual(1, propositionsArray?.count) - let prop = propositionsArray?.first! - XCTAssertEqual("propId", prop?["id"] as? String) - XCTAssertEqual("propScope", prop?["scope"] as? String) - let scopeDetails = prop?["scopeDetails"] as? [String: Any] - XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) - let characteristics = scopeDetails?["characteristics"] as? [String: Any] - XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let dispatchedEvent = mockRuntime.firstEvent +// // validate type and source +// XCTAssertEqual(EventType.edge, dispatchedEvent?.type) +// XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) +// // validate event mask entries +// XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) +// XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) +// XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) +// // validate xdm map +// let dispatchedEventData = dispatchedEvent?.data +// let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] +// XCTAssertEqual("decisioning.propositionDismiss", dispatchedXdmMap?["eventType"] as? String) +// let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] +// let decisioningMap = experienceMap?["decisioning"] as? [String: Any] +// let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] +// XCTAssertEqual(1, propositionEventTypeMap?["dismiss"] as? Int) +// let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] +// XCTAssertNil(propositionActionMap) +// let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] +// XCTAssertEqual(1, propositionsArray?.count) +// let prop = propositionsArray?.first! +// XCTAssertEqual("propId", prop?["id"] as? String) +// XCTAssertEqual("propScope", prop?["scope"] as? String) +// let scopeDetails = prop?["scopeDetails"] as? [String: Any] +// XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) +// let characteristics = scopeDetails?["characteristics"] as? [String: Any] +// XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) } func testSendPropositionInteractionTrigger() throws { // setup setConfigSharedState() setIdentitySharedState() - let mockEvent = Event(name: "triggeringEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: nil) - let mockEdgeEventType = MessagingEdgeEventType.inappTrigger + let mockEvent = Event(name: "triggeringEvent", type: EventType.messaging, source: EventSource.requestContent, data: nil) + let mockEdgeEventType = MessagingEdgeEventType.trigger let mockInteraction = "swords" let mockMessageId = "SUCHMESSAGEVERYID" - let mockMessage = MockMessage(parent: messaging, event: mockEvent) + let mockMessage = MockMessage(parent: messaging, triggeringEvent: mockEvent) mockMessage.propositionInfo = PropositionInfo(id: "propId", scope: "propScope", scopeDetails: ["activity":["id":mockMessageId], "correlationID": "mockCorrelationID", "characteristics":["cjmEventToken":"abcd"]]) // test - messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) + messaging.sendPropositionInteraction(withXdm: [:]) +// messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) // verify - XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let dispatchedEvent = mockRuntime.firstEvent - // validate type and source - XCTAssertEqual(EventType.edge, dispatchedEvent?.type) - XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) - // validate event mask entries - XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) - XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) - XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) - // validate xdm map - let dispatchedEventData = dispatchedEvent?.data - let dispatchedEventHistoryData = dispatchedEventData?["iam"] as? [String: Any] - XCTAssertEqual(mockInteraction, dispatchedEventHistoryData?["action"] as? String) - XCTAssertEqual(mockEdgeEventType.propositionEventType, dispatchedEventHistoryData?["eventType"] as? String) - XCTAssertEqual(mockMessageId, dispatchedEventHistoryData?["id"] as? String) - let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] - XCTAssertEqual("decisioning.propositionTrigger", dispatchedXdmMap?["eventType"] as? String) - let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] - let decisioningMap = experienceMap?["decisioning"] as? [String: Any] - let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] - XCTAssertEqual(1, propositionEventTypeMap?["trigger"] as? Int) - let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] - XCTAssertNil(propositionActionMap) - let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] - XCTAssertEqual(1, propositionsArray?.count) - let prop = propositionsArray?.first! - XCTAssertEqual("propId", prop?["id"] as? String) - XCTAssertEqual("propScope", prop?["scope"] as? String) - let scopeDetails = prop?["scopeDetails"] as? [String: Any] - XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) - let characteristics = scopeDetails?["characteristics"] as? [String: Any] - XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let dispatchedEvent = mockRuntime.firstEvent +// // validate type and source +// XCTAssertEqual(EventType.edge, dispatchedEvent?.type) +// XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) +// // validate event mask entries +// XCTAssertEqual("iam.eventType", dispatchedEvent?.mask?[0]) +// XCTAssertEqual("iam.id", dispatchedEvent?.mask?[1]) +// XCTAssertEqual("iam.action", dispatchedEvent?.mask?[2]) +// // validate xdm map +// let dispatchedEventData = dispatchedEvent?.data +// let dispatchedEventHistoryData = dispatchedEventData?["iam"] as? [String: Any] +// XCTAssertEqual(mockInteraction, dispatchedEventHistoryData?["action"] as? String) +// XCTAssertEqual(mockEdgeEventType.propositionEventType, dispatchedEventHistoryData?["eventType"] as? String) +// XCTAssertEqual(mockMessageId, dispatchedEventHistoryData?["id"] as? String) +// let dispatchedXdmMap = dispatchedEventData?["xdm"] as? [String: Any] +// XCTAssertEqual("decisioning.propositionTrigger", dispatchedXdmMap?["eventType"] as? String) +// let experienceMap = dispatchedXdmMap?["_experience"] as? [String: Any] +// let decisioningMap = experienceMap?["decisioning"] as? [String: Any] +// let propositionEventTypeMap = decisioningMap?["propositionEventType"] as? [String: Any] +// XCTAssertEqual(1, propositionEventTypeMap?["trigger"] as? Int) +// let propositionActionMap = decisioningMap?["propositionAction"] as? [String: Any] +// XCTAssertNil(propositionActionMap) +// let propositionsArray = decisioningMap?["propositions"] as? [[String: Any]] +// XCTAssertEqual(1, propositionsArray?.count) +// let prop = propositionsArray?.first! +// XCTAssertEqual("propId", prop?["id"] as? String) +// XCTAssertEqual("propScope", prop?["scope"] as? String) +// let scopeDetails = prop?["scopeDetails"] as? [String: Any] +// XCTAssertEqual("mockCorrelationID", scopeDetails?["correlationID"] as? String) +// let characteristics = scopeDetails?["characteristics"] as? [String: Any] +// XCTAssertEqual("abcd", characteristics?["cjmEventToken"] as? String) } func testSendPropositionInteractionNoScopeDetails() throws { // setup setConfigSharedState() setIdentitySharedState() - let mockEvent = Event(name: "triggeringEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: nil) - let mockEdgeEventType = MessagingEdgeEventType.inappInteract + let mockEvent = Event(name: "triggeringEvent", type: EventType.messaging, source: EventSource.requestContent, data: nil) + let mockEdgeEventType = MessagingEdgeEventType.interact let mockInteraction = "swords" - let mockMessage = MockMessage(parent: messaging, event: mockEvent) + let mockMessage = MockMessage(parent: messaging, triggeringEvent: mockEvent) // test - messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) + messaging.sendPropositionInteraction(withXdm: [:]) +// messaging.sendPropositionInteraction(withEventType: mockEdgeEventType, andInteraction: mockInteraction, forMessage: mockMessage) // verify - XCTAssertEqual(0, mockRuntime.dispatchedEvents.count) +// XCTAssertEqual(0, mockRuntime.dispatchedEvents.count) } } diff --git a/AEPMessaging/Tests/UnitTests/Messaging+PublicApiTest.swift b/AEPMessaging/Tests/UnitTests/Messaging+PublicApiTest.swift index de4dca7b..5b95fc81 100644 --- a/AEPMessaging/Tests/UnitTests/Messaging+PublicApiTest.swift +++ b/AEPMessaging/Tests/UnitTests/Messaging+PublicApiTest.swift @@ -11,258 +11,639 @@ // @testable import AEPCore +@testable import AEPServices @testable import AEPMessaging import UserNotifications import XCTest +import AEPTestUtils -class MessagingPublicApiTest: XCTestCase { +class MessagingPublicApiTest: XCTestCase, AnyCodableAsserts { + let ASYNC_TIMEOUT = 2.0 - var mockXdmData: [String: Any] = ["somekey": "somedata"] - var notificationContent: [AnyHashable: Any] = [:] + static let MOCK_TRACKING_DETAILS : [String:Any] = [MessagingConstants.XDM.AdobeKeys._XDM: + ["trackingKey": "trackingValue"]] + static let WEB_URL = URL(string: "https://adobe.com")! + static let DEEPLINK_URL = URL(string: "deeplink://")! + let MOCK_BUNDLE_IDENTIFIER = "mobileapp://com.adobe.ajo.e2eTestApp" + let MOCK_FEEDS_SURFACE = "mobileapp://com.adobe.ajo.e2eTestApp/promos/feed1" + override func setUp() { - notificationContent = [MessagingConstants.XDM.AdobeKeys._XDM: mockXdmData] - MockExtension.reset() + registerNotificationCategories() EventHub.shared.start() registerMockExtension(MockExtension.self) } - + + override func tearDown() { + resetNotificationCategories() + MockExtension.reset() + EventHub.reset() + } + private func registerMockExtension(_ type: T.Type) { let semaphore = DispatchSemaphore(value: 0) EventHub.shared.registerExtension(type) { _ in semaphore.signal() } - + semaphore.wait() } - - func testHandleNotificationResponse_happy() { - let expectation = XCTestExpectation(description: "Messaging request event") - let mockCustomActionId = "mockCustomActionId" - let mockIdentifier = "mockIdentifier" - expectation.assertForOverFulfill = true - - EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in - XCTAssertEqual(MessagingConstants.Event.Name.PUSH_NOTIFICATION_INTERACTION, event.name) - XCTAssertEqual(MessagingConstants.Event.EventType.messaging, event.type) - XCTAssertEqual(EventSource.requestContent, event.source) - - guard let eventData = event.data, - let applicationOpened = eventData[MessagingConstants.Event.Data.Key.APPLICATION_OPENED] as? Bool, - let eventDataType = eventData[MessagingConstants.Event.Data.Key.EVENT_TYPE] as? String, - let actionId = eventData[MessagingConstants.Event.Data.Key.ACTION_ID] as? String, - let messageId = eventData[MessagingConstants.Event.Data.Key.MESSAGE_ID] as? String, - let xdm = eventData[MessagingConstants.Event.Data.Key.ADOBE_XDM] as? [String: Any] - else { - XCTFail() - expectation.fulfill() - return + + + func testHandleNotificationResponse_when_notificationTapped() { + // mock notification response indicating click of notification + let mockedResponse = createNotificationResponse(actionId: UNNotificationDefaultActionIdentifier) + + // create your expectations + let expectation = XCTestExpectation(description: "messaging requestContent event dispatched") + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + let expectedEventData = #""" + { + "eventType": "pushTracking.applicationOpened", + "applicationOpened": true, + "id": "mockIdentifier", + "clickThroughUrl": "https://adobe.com", + "adobe_xdm": { + "trackingKey": "trackingValue" + } } - - XCTAssertTrue(applicationOpened) - XCTAssertEqual(MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION, eventDataType) - XCTAssertEqual(actionId, mockCustomActionId) - XCTAssertEqual(messageId, mockIdentifier) - XCTAssertNotNil(xdm) - XCTAssertEqual(xdm.count, 1) - XCTAssertEqual(xdm["somekey"] as? String, "somedata") - + """#.toAnyCodable() + self.assertEqual(expected: event.data?.toAnyCodable(), actual: expectedEventData) expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = self.notificationContent - - let request = UNNotificationRequest(identifier: mockIdentifier, content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return + + // test + Messaging.handleNotificationResponse(mockedResponse) + + // verify expectation + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + + func testHandleNotificationResponse_when_notificationDismissed() { + // mock notification response indicating notification dismiss + let mockedResponse = createNotificationResponse(actionId: UNNotificationDismissActionIdentifier) + + // create your expectations + let expectation = XCTestExpectation(description: "messaging requestContent event dispatched") + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + let expectedEventData = #""" + { + "eventType": "pushTracking.customAction", + "actionId": "Dismiss", + "applicationOpened" : false, + "id" : "mockIdentifier", + "adobe_xdm": { + "trackingKey": "trackingValue" + } + } + """#.toAnyCodable() + self.assertEqual(expected: expectedEventData, actual: event.data?.toAnyCodable()) + expectation.fulfill() } - - Messaging.handleNotificationResponse(response, applicationOpened: true, customActionId: mockCustomActionId) + + // test + Messaging.handleNotificationResponse(mockedResponse) + + // verify expectation wait(for: [expectation], timeout: ASYNC_TIMEOUT) } - - func testHandleNotificationResponse_when_applicationOpenedFalse_andNilCustomActionID() { - let expectation = XCTestExpectation(description: "Messaging request event") - expectation.assertForOverFulfill = true - - EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in - XCTAssertEqual(MessagingConstants.Event.Name.PUSH_NOTIFICATION_INTERACTION, event.name) - XCTAssertEqual(MessagingConstants.Event.EventType.messaging, event.type) - XCTAssertEqual(EventSource.requestContent, event.source) - - guard let eventData = event.data else { - XCTFail() - expectation.fulfill() - return + + func testHandleNotificationResponse_when_customAction_opensApp() { + // mock notification response indicating custom action that opens the application + // For more details about the actionIdentifier registered with the notification look at method `registerNotificationCategories` + // the actionID "open" is regsitered to open the application + let mockedResponse = createNotificationResponse(actionId: "open") + + // create your expectations + let expectation = XCTestExpectation(description: "messaging requestContent event dispatched") + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + let expectedEventData = #""" + { + "eventType": "pushTracking.customAction", + "actionId": "open", + "applicationOpened" : true, + "id" : "mockIdentifier", + "adobe_xdm": { + "trackingKey": "trackingValue" + } } - - XCTAssertFalse(eventData[MessagingConstants.Event.Data.Key.APPLICATION_OPENED] as? Bool ?? true) - XCTAssertNil(eventData[MessagingConstants.Event.Data.Key.ACTION_ID] as? String) + """#.toAnyCodable() + self.assertEqual(expected: expectedEventData, actual: event.data?.toAnyCodable()) expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = self.notificationContent - - let request = UNNotificationRequest(identifier: "mockIdentifier", content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return + + // test + Messaging.handleNotificationResponse(mockedResponse) + + // verify expectation + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + + func testHandleNotificationResponse_when_customAction_doesNotOpenApp() { + // Mock notification response indicating custom action that does not opens the application + // For more details about the actionIdentifier registered with the notification look at method `registerNotificationCategories` + // the actionID "cancel" is regsitered to dismiss the notification + let mockedResponse = createNotificationResponse(actionId: "cancel") + + // create your expectations + let expectation = XCTestExpectation(description: "messaging requestContent event dispatched") + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + let expectedEventData = #""" + { + "eventType": "pushTracking.customAction", + "actionId": "cancel", + "applicationOpened" : false, + "id" : "mockIdentifier", + "adobe_xdm": { + "trackingKey": "trackingValue" + } + } + """#.toAnyCodable() + self.assertEqual(expected: expectedEventData, actual: event.data?.toAnyCodable()) + expectation.fulfill() } - Messaging.handleNotificationResponse(response, applicationOpened: false, customActionId: nil) + + // test + Messaging.handleNotificationResponse(mockedResponse) + + // verify expectation wait(for: [expectation], timeout: ASYNC_TIMEOUT) } + + + func testHandleNotificationResponse_when_noTrackingData() { + // Mock notification response that contains no tracking data + let mockedResponse = createNotificationResponse(actionId: "cancel", trackingData: nil) + + // create your expectations + let eventExpectation = XCTestExpectation(description: "messaging requestContent event not dispatched") + eventExpectation.isInverted = true + let callbackExpectation = XCTestExpectation(description: "correct push tracking status was returned") + + // register listener to verify messaging request content event + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + eventExpectation.fulfill() + } + + // test + Messaging.handleNotificationResponse(mockedResponse, closure: { status in + XCTAssertEqual(status, .noTrackingData) + callbackExpectation.fulfill() + }) + + // verify expectation + wait(for: [eventExpectation, callbackExpectation], timeout: ASYNC_TIMEOUT) + } + + func testHandleNotificationResponse_when_URLNotHandledByApplication() { + // Mock notification response that contains no tracking data + let mockedResponse = createNotificationResponse(url: MessagingPublicApiTest.WEB_URL) + + // create your expectations + let eventExpectation = XCTestExpectation(description: "messaging requestContent event dispatched") + + // register listener to verify messaging request content event + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + XCTAssertEqual(event.data!["clickThroughUrl"] as! String, MessagingPublicApiTest.WEB_URL.absoluteString) + eventExpectation.fulfill() + } + + // test + Messaging.handleNotificationResponse(mockedResponse, urlHandler: { link in + // returning false to indicate that the URL was not handled by the application + return false + }) + + // verify expectation + wait(for: [eventExpectation], timeout: ASYNC_TIMEOUT) + } - func testHandleNotificationResponse_when_noXdmInNotification() { - let expectation = XCTestExpectation(description: "Messaging request event") - let mockCustomActionId = "mockCustomActionId" - let mockIdentifier = "mockIdentifier" + func testHandleNotificationResponse_when_URLHandledByApplication() { + // Mock notification response with a deeplink URL + let mockedResponse = createNotificationResponse(url: MessagingPublicApiTest.DEEPLINK_URL) + + // create your expectations + let eventExpectation = XCTestExpectation(description: "messaging requestContent event dispatched") + + // register listener to verify messaging request content event + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + // verify clickThroughUrl is not present in the event data + XCTAssertNil(event.data!["clickThroughUrl"]) + eventExpectation.fulfill() + } + + // test + Messaging.handleNotificationResponse(mockedResponse, urlHandler: { link in + // returning true to indicate that the URL was handled by the application + return true + }) + + // verify expectation + wait(for: [eventExpectation], timeout: ASYNC_TIMEOUT) + } + + + func testHandleNotificationResponse_when_customAction_then_NoClickThroughUrlAttached() { + // Mock notification response indicating custom action being taken on a notification + let mockedResponse = createNotificationResponse(actionId: "open", url: MessagingPublicApiTest.DEEPLINK_URL) + + // create your expectations + let eventExpectation = XCTestExpectation(description: "clickthrough url should not be present in event payload") + + // register listener to verify messaging request content event + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + // verify clickThroughUrl is not present in the event data + // "ClickthroughUrl" key will only exist when notification body is tapped + XCTAssertNil(event.data!["clickThroughUrl"]) + eventExpectation.fulfill() + } + + // test + Messaging.handleNotificationResponse(mockedResponse, urlHandler: { link in + // returning true to indicate that the URL was handled by the application + return true + }) + + // verify expectation + wait(for: [eventExpectation], timeout: ASYNC_TIMEOUT) + } + + + func testHandleNotificationResponse_when_urlHandlerIsNil_then_ClickThroughUrlAttached() { + // Mock notification response indicating custom action being taken on a notification + let mockedResponse = createNotificationResponse(url: MessagingPublicApiTest.DEEPLINK_URL) + + // create your expectations + let eventExpectation = XCTestExpectation(description: "clickthrough url should be present in event payload") + + // register listener to verify messaging request content event + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + XCTAssertNotNil(event.data!["clickThroughUrl"]) + eventExpectation.fulfill() + } + + // test + Messaging.handleNotificationResponse(mockedResponse, urlHandler: nil) + + // verify expectation + wait(for: [eventExpectation], timeout: ASYNC_TIMEOUT) + } + + + func testRefreshInAppMessages() throws { + // setup + let expectation = XCTestExpectation(description: "Refresh In app messages event") expectation.assertForOverFulfill = true - expectation.isInverted = true - - EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + MobileCore.registerEventListener(type: EventType.messaging, source: EventSource.requestContent) { event in + XCTAssertEqual(MessagingConstants.Event.Name.REFRESH_MESSAGES, event.name) + let expectedEventData = #""" + { + "refreshmessages": true + } + """#.toAnyCodable() + self.assertEqual(expected: expectedEventData, actual: event.data?.toAnyCodable()) expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - - let request = UNNotificationRequest(identifier: mockIdentifier, content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return - } - - Messaging.handleNotificationResponse(response, applicationOpened: true, customActionId: mockCustomActionId) - wait(for: [expectation], timeout: 1) + + // test + Messaging.refreshInAppMessages() + + // verify + wait(for: [expectation], timeout: ASYNC_TIMEOUT) } - - func testHandleNotificationResponse_when_emptyMessageId() { - let expectation = XCTestExpectation(description: "Messaging request event") - let mockCustomActionId = "mockCustomActionId" - let mockIdentifier = "" + + // MARK: - updatePropositionsForSurfaces + + func testUpdatePropositionsForSurfaces() throws { + // setup + let expectation = XCTestExpectation(description: "updatePropositionsForSurfaces should dispatch an event with expected data.") expectation.assertForOverFulfill = true - expectation.isInverted = true - + + let testEvent = Event(name: "Update propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: [ + "updatepropositions": true, + "surfaces": [ + [ "uri": "promos/feed1" ], + [ "uri": "promos/feed2" ] + ] + ]) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { _ in - XCTFail() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: testEvent.type, source: testEvent.source) { event in + XCTAssertEqual(testEvent.name, event.name) + XCTAssertNotNil(event.data) + XCTAssertEqual(true, event.data?["updatepropositions"] as? Bool) + guard let surfaces = event.data?["surfaces"] as? [[String: Any]], !surfaces.isEmpty else { + XCTFail("Surface path strings array should be valid.") + return + } + XCTAssertEqual(2, surfaces.count) + XCTAssertEqual("\(self.MOCK_BUNDLE_IDENTIFIER)/promos/feed1", surfaces[0]["uri"] as? String) + XCTAssertEqual("\(self.MOCK_BUNDLE_IDENTIFIER)/promos/feed2", surfaces[1]["uri"] as? String) + expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - - let request = UNNotificationRequest(identifier: mockIdentifier, content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return + + // test + Messaging.updatePropositionsForSurfaces([ + Surface(path: "promos/feed1"), + Surface(path: "promos/feed2") + ]) + + // verify + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + func testUpdatePropositionsForSurfaces_whenValidAndEmptySurfacesInArray() throws { + // setup + let expectation = XCTestExpectation(description: "updatePropositionsForSurfaces should dispatch an event with expected data.") + expectation.assertForOverFulfill = true + + let testEvent = Event(name: "Update propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: [ + "updatepropositions": true, + "surfaces": [ + [ : ], + [ "uri": "promos/feed2" ] + ] + ]) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: testEvent.type, source: testEvent.source) { event in + XCTAssertEqual(testEvent.name, event.name) + XCTAssertNotNil(event.data) + XCTAssertEqual(true, event.data?["updatepropositions"] as? Bool) + guard let surfaces = event.data?["surfaces"] as? [[String: Any]], !surfaces.isEmpty else { + XCTFail("Surface path strings array should be valid.") + return + } + XCTAssertEqual(2, surfaces.count) + XCTAssertEqual(self.MOCK_BUNDLE_IDENTIFIER, surfaces[0]["uri"] as? String) + XCTAssertEqual("\(self.MOCK_BUNDLE_IDENTIFIER)/promos/feed2", surfaces[1]["uri"] as? String) + + expectation.fulfill() } - - Messaging.handleNotificationResponse(response, applicationOpened: true, customActionId: mockCustomActionId) + + // test + Messaging.updatePropositionsForSurfaces([ + Surface(path: ""), + Surface(path: "promos/feed2") + ]) + + // verify wait(for: [expectation], timeout: ASYNC_TIMEOUT) } - func testHandleNotificationResponseWithParametersAPI_when_emptyXdmInNotification() { - let expectation = XCTestExpectation(description: "Messaging request event") - let mockIdentifier = "mockIdentifier" + func testUpdatePropositionsForSurfaces_whenEmptySurfaceInArray() { + // setup + let expectation = XCTestExpectation(description: "updatePropositionsForSurfaces should dispatch an event.") expectation.assertForOverFulfill = true + + let testEvent = Event(name: "Update propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: [ + "updatepropositions": true, + "surfaces": [ + [ : ] + ] + ]) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener( + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent") { event in + + XCTAssertEqual(testEvent.name, event.name) + XCTAssertNotNil(event.data) + XCTAssertEqual(true, event.data?["updatepropositions"] as? Bool) + guard let surfaces = event.data?["surfaces"] as? [[String: Any]], !surfaces.isEmpty else { + XCTFail("Surface path strings array should be valid.") + return + } + XCTAssertEqual(1, surfaces.count) + XCTAssertEqual(self.MOCK_BUNDLE_IDENTIFIER, surfaces[0]["uri"] as? String) + + expectation.fulfill() + } + + // test + Messaging.updatePropositionsForSurfaces([ + Surface(path: "") + ]) + + // verify + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + func testUpdatePropositionsForSurfaces_whenEmptySurfacesArray() { + // setup + let expectation = XCTestExpectation(description: "updatePropositionsForSurfaces should not dispatch an event.") expectation.isInverted = true - + + // test + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener( + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent") { _ in + expectation.fulfill() + } + + // test + Messaging.updatePropositionsForSurfaces([]) + + // verify + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + // MARK: - getPropositionsForSurfaces + + func testGetPropositionsForSurfacesNoValidSurfaces() throws { + // setup + let expectation = XCTestExpectation(description: "completion should be called with invalidRequest") + let eventExpectation = XCTestExpectation(description: "event should be dispatched") + eventExpectation.isInverted = true EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: EventType.messaging, source: EventSource.requestContent) { _ in + eventExpectation.fulfill() + } - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in + let surfacePaths = [Surface(uri: "")] + + // test + Messaging.getPropositionsForSurfaces(surfacePaths) { surfacePropositions, error in + XCTAssertNil(surfacePropositions) + XCTAssertNotNil(error) + XCTAssertEqual(AEPError.invalidRequest, error as? AEPError) expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = ["_xdm" : [:] as [String:Any]] - - let request = UNNotificationRequest(identifier: mockIdentifier, content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return + + // verify + wait(for: [expectation, eventExpectation], timeout: ASYNC_TIMEOUT) + } + + func testGetPropositionsForSurfacesTimeoutCallback() throws { + // setup + let expectation = XCTestExpectation(description: "completion should be called with responseEvent") + let eventExpectation = XCTestExpectation(description: "event should be dispatched") + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: EventType.messaging, source: EventSource.requestContent) { event in + eventExpectation.fulfill() + // don't send a response event } - - Messaging.handleNotificationResponse(response, applicationOpened: true, customActionId: "customActionId") - wait(for: [expectation], timeout: 1) + + let surfacePaths = [Surface(uri: MOCK_FEEDS_SURFACE)] + + // test + Messaging.getPropositionsForSurfaces(surfacePaths) { surfacePropositions, error in + XCTAssertNil(surfacePropositions) + XCTAssertEqual(AEPError.callbackTimeout, error as? AEPError) + expectation.fulfill() + } + + // verify + wait(for: [expectation, eventExpectation], timeout: ASYNC_TIMEOUT) } - func testHandleNotificationResponse_when_emptyXdmInNotification() { - var acutalStatus : PushTrackingStatus? - let expectation = XCTestExpectation(description: "Messaging request event") - let mockIdentifier = "mockIdentifier" - expectation.assertForOverFulfill = true - + func testGetPropositionsForSurfacesErrorPopulated() throws { + // setup + let expectation = XCTestExpectation(description: "completion should be called with responseEvent") + let eventExpectation = XCTestExpectation(description: "event should be dispatched") EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: EventType.messaging, source: EventSource.requestContent) { event in + eventExpectation.fulfill() + + // dispatch the response + let propositionJson = JSONFileLoader.getRulesJsonFromFile("inappPropositionV1") + let responseEvent = event.createResponseEvent(name: "name", type: "type", source: "source", data: [ + "propositions": [ propositionJson ], + "responseerror": AEPError.serverError.rawValue + ]) + MobileCore.dispatch(event: responseEvent) + } + + let surfacePaths = [Surface(uri: MOCK_FEEDS_SURFACE)] - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in + // test + Messaging.getPropositionsForSurfaces(surfacePaths) { surfacePropositions, error in + XCTAssertNil(surfacePropositions) + XCTAssertEqual(AEPError.serverError, error as? AEPError) expectation.fulfill() } - - let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) - let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = ["_xdm" : [:] as [String:Any]] - - let request = UNNotificationRequest(identifier: mockIdentifier, content: notificationContent, trigger: trigger) - guard let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request)) else { - XCTFail() - return + + // verify + wait(for: [expectation, eventExpectation], timeout: ASYNC_TIMEOUT) + } + + func testGetPropositionsForSurfacesNoSurfaces() throws { + // setup + let expectation = XCTestExpectation(description: "completion should be called with responseEvent") + let eventExpectation = XCTestExpectation(description: "event should be dispatched") + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: EventType.messaging, source: EventSource.requestContent) { event in + eventExpectation.fulfill() + + // dispatch the response + let responseEvent = event.createResponseEvent(name: "name", type: "type", source: "source", data: [:]) + MobileCore.dispatch(event: responseEvent) } - - Messaging.handleNotificationResponse(response, closure: { status in - acutalStatus = status + + let surfacePaths = [Surface(uri: MOCK_FEEDS_SURFACE)] + + // test + Messaging.getPropositionsForSurfaces(surfacePaths) { surfacePropositions, error in + XCTAssertNil(surfacePropositions) + XCTAssertEqual(AEPError.unexpected, error as? AEPError) expectation.fulfill() - }) + } - XCTAssertEqual(.noTrackingData , acutalStatus) - wait(for: [expectation], timeout: 1) + // verify + wait(for: [expectation, eventExpectation], timeout: ASYNC_TIMEOUT) } - - func testRefreshInAppMessages() throws { + func testGetPropositionsForSurfacesHappy() throws { // setup - let expectation = XCTestExpectation(description: "Refresh In app messages event") - expectation.assertForOverFulfill = true - + let expectation = XCTestExpectation(description: "completion should be called with responseEvent") + let eventExpectation = XCTestExpectation(description: "event should be dispatched") EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() - EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent) { event in - XCTAssertEqual(MessagingConstants.Event.Name.REFRESH_MESSAGES, event.name) - XCTAssertEqual(MessagingConstants.Event.EventType.messaging, event.type) - XCTAssertEqual(EventSource.requestContent, event.source) - - guard let eventData = event.data else { - XCTFail() - expectation.fulfill() - return + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: EventType.messaging, source: EventSource.requestContent) { event in + // verify incoming request + XCTAssertNotNil(event) + let eventData = event.data + XCTAssertEqual(true, eventData?["getpropositions"] as? Bool) + let surfacesMap = eventData?["surfaces"] as? [[String: Any]] + let dispatchedSurface = surfacesMap?.first + let surfaceUri = dispatchedSurface?["uri"] as? String + XCTAssertNotNil(surfaceUri) + XCTAssertEqual(self.MOCK_FEEDS_SURFACE, surfaceUri) + eventExpectation.fulfill() + + // dispatch the response + let propositionJson = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2") + let responseEvent = event.createResponseEvent(name: "name", type: "type", source: "source", data: ["propositions": [ propositionJson ]]) + MobileCore.dispatch(event: responseEvent) + } + + let surfacePaths = [Surface(uri: MOCK_FEEDS_SURFACE)] + + // test + Messaging.getPropositionsForSurfaces(surfacePaths) { surfacePropositions, error in + XCTAssertEqual(1, surfacePropositions?.count) + if let aepError = error as? AEPError { + XCTAssertEqual(aepError, .none) } - - XCTAssertTrue(eventData[MessagingConstants.Event.Data.Key.REFRESH_MESSAGES] as? Bool ?? false) expectation.fulfill() } - - // test - Messaging.refreshInAppMessages() - + // verify - wait(for: [expectation], timeout: ASYNC_TIMEOUT) + wait(for: [expectation, eventExpectation], timeout: ASYNC_TIMEOUT) + } + + /// Private Helper methods + private func createNotificationResponse(actionId : String = UNNotificationDefaultActionIdentifier, + trackingData : [String : Any]? = MOCK_TRACKING_DETAILS, + url : URL = WEB_URL) -> UNNotificationResponse { + + let dateInfo = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) + let trigger = UNCalendarNotificationTrigger(dateMatching: dateInfo, repeats: false) + let notificationContent = UNMutableNotificationContent() + var userInfo : [String : Any] = [MessagingConstants.PushNotification.UserInfoKey.ACTION_URL : url.absoluteString] + if let trackingData = trackingData { + for (key, value) in trackingData { + userInfo[key] = value + } + } + notificationContent.userInfo = userInfo + notificationContent.categoryIdentifier = "categoryId" + let request = UNNotificationRequest(identifier: "mockIdentifier", + content: notificationContent, + trigger: trigger) + let response = UNNotificationResponse(coder: MockNotificationResponseCoder(with: request, + actionIdentifier: actionId))! + return response + } + + private func registerNotificationCategories() { + // Define actions + let action1 = UNNotificationAction(identifier: "open", title: "OPEN", options: [.foreground]) + let action2 = UNNotificationAction(identifier: "cancel", title: "CANCEL", options: [.destructive]) + + // Define category with actions + let category = UNNotificationCategory(identifier: "categoryId", actions: [action1, action2], intentIdentifiers: [], options: [.customDismissAction]) + + // Register the category + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + private func resetNotificationCategories() { + // Register the category + UNUserNotificationCenter.current().setNotificationCategories([]) } } diff --git a/AEPMessaging/Tests/UnitTests/Messaging+StateTests.swift b/AEPMessaging/Tests/UnitTests/Messaging+StateTests.swift new file mode 100644 index 00000000..01c99b15 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/Messaging+StateTests.swift @@ -0,0 +1,144 @@ +// +// Copyright 2023 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. +// + +@testable import AEPCore +@testable import AEPServices +@testable import AEPMessaging +import AEPTestUtils +import XCTest + +class MessagingPlusStateTests: XCTestCase { + var mockRuntime: TestableExtensionRuntime! + var messaging: Messaging! + var mockMessagingRulesEngine: MockMessagingRulesEngine! + var mockLaunchRulesEngineForIAM: MockLaunchRulesEngine! + var mockFeedRulesEngine: MockFeedRulesEngine! + var mockLaunchRulesEngineForFeeds: MockLaunchRulesEngine! + var mockCache: MockCache! + let mockIamSurface = Surface(uri: "mobileapp://com.apple.dt.xctest.tool") + var mockProposition: Proposition! + var mockPropositionItem: PropositionItem! + var mockPropositionInfo: PropositionInfo! + + // Mock constants + let MOCK_ECID = "mock_ecid" + let MOCK_EVENT_DATASET = "mock_event_dataset" + let MOCK_EXP_ORG_ID = "mock_exp_org_id" + let MOCK_PUSH_TOKEN = "mock_pushToken" + let MOCK_PUSH_PLATFORM = "apns" + + override func setUp() { + mockRuntime = TestableExtensionRuntime() + mockCache = MockCache(name: "mockCache") + mockLaunchRulesEngineForIAM = MockLaunchRulesEngine(name: "mockLaunchRulesEngineIAM", extensionRuntime: mockRuntime) + mockMessagingRulesEngine = MockMessagingRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngineForIAM, cache: mockCache) + mockLaunchRulesEngineForFeeds = MockLaunchRulesEngine(name: "mockLaunchRulesEngineFeeds", extensionRuntime: mockRuntime) + mockFeedRulesEngine = MockFeedRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngineForFeeds) + messaging = Messaging(runtime: mockRuntime, rulesEngine: mockMessagingRulesEngine, feedRulesEngine: mockFeedRulesEngine, expectedSurfaceUri: mockIamSurface.uri, cache: mockCache) + + mockPropositionItem = PropositionItem(itemId: "propItemId", schema: .defaultContent, itemData: [:]) + mockProposition = Proposition(uniqueId: "propId", scope: mockIamSurface.uri, scopeDetails: [:], items: [mockPropositionItem]) + mockPropositionInfo = PropositionInfo(id: "propInfoId", scope: mockIamSurface.uri, scopeDetails: [:]) + } + + func testLoadCachedPropositionsCacheContainsPropositions() throws { + // setup + let propositionData = JSONFileLoader.getRulesStringFromFile("cachedProposition").data(using: .utf8)! + mockCache.getReturnValue = CacheEntry(data: propositionData, expiry: .never, metadata: nil) + + // test + messaging.loadCachedPropositions() + + // verify + XCTAssertTrue(mockLaunchRulesEngineForIAM.replaceRulesCalled) + } + + func testLoadCachedPropositionsCacheDoesNotContainPropositions() throws { + // setup + mockCache.getReturnValue = nil + + // test + messaging.loadCachedPropositions() + + // verify + XCTAssertFalse(mockLaunchRulesEngineForIAM.replaceRulesCalled) + } + + func testUpdatePropositionInfo() throws { + // setup + messaging.propositionInfo = [ "id1": mockPropositionInfo ] + let propInfo2 = PropositionInfo(id: "propInfoId2", scope: "newScope", scopeDetails: [:]) + let newPropInfoDictionary = [ "id2": propInfo2 ] + + // test + messaging.updatePropositionInfo(newPropInfoDictionary) + + // verify + XCTAssertEqual(2, messaging.propositionInfoCount()) + } + + func testUpdatePropositionInfoRemovingSurfaces() throws { + // setup + messaging.propositionInfo = [ "id1": mockPropositionInfo ] + let propInfo2 = PropositionInfo(id: "propInfoId2", scope: "newScope", scopeDetails: [:]) + let newPropInfoDictionary = [ "id2": propInfo2 ] + + // test + messaging.updatePropositionInfo(newPropInfoDictionary, removing: [mockIamSurface]) + + // verify + XCTAssertEqual(1, messaging.propositionInfoCount()) + XCTAssertNil(messaging.propositionInfo["id1"]) + } + + func testUpdatePropositionInfoNewOverrides() throws { + // setup + messaging.propositionInfo = [ "id1": mockPropositionInfo ] + let propInfo2 = PropositionInfo(id: "propInfoId2", scope: "newScope", scopeDetails: [:]) + let newPropInfoDictionary = [ "id1": propInfo2 ] + + // test + messaging.updatePropositionInfo(newPropInfoDictionary, removing: [mockIamSurface]) + + // verify + XCTAssertEqual(1, messaging.propositionInfoCount()) + let propInfo = messaging.propositionInfo["id1"] + XCTAssertEqual("newScope", propInfo?.scope) + } + + func testUpdatePropositions() throws { + // setup + messaging.propositions = [ mockIamSurface: [mockProposition] ] + let newProp = Proposition(uniqueId: "newId", scope: "newScope", scopeDetails: [:], items: []) + let newPropositions = [Surface(uri: "newScope"): [newProp]] + + // test + messaging.updatePropositions(newPropositions) + + // verify + XCTAssertEqual(2, messaging.propositions.count) + } + + func testUpdatePropositionsRemovingSurfaces() throws { + // setup + messaging.propositions = [ mockIamSurface: [mockProposition] ] + let newProp = Proposition(uniqueId: "newId", scope: "newScope", scopeDetails: [:], items: []) + let newPropositions = [Surface(uri: "newScope"): [newProp]] + + // test + messaging.updatePropositions(newPropositions, removing: [mockIamSurface]) + + // verify + XCTAssertEqual(1, messaging.propositions.count) + XCTAssertNil(messaging.propositions[mockIamSurface]) + } +} diff --git a/AEPMessaging/Tests/UnitTests/MessagingEdgeEventTypeTests.swift b/AEPMessaging/Tests/UnitTests/MessagingEdgeEventTypeTests.swift index 11deb360..b84b0499 100644 --- a/AEPMessaging/Tests/UnitTests/MessagingEdgeEventTypeTests.swift +++ b/AEPMessaging/Tests/UnitTests/MessagingEdgeEventTypeTests.swift @@ -15,39 +15,39 @@ import Foundation import XCTest class MessagingEdgeEventTypeTests: XCTestCase { - func testInAppDismiss() throws { + func testDismiss() throws { // setup - let value = MessagingEdgeEventType(rawValue: 0) + let value = MessagingEdgeEventType(rawValue: 6) // verify - XCTAssertEqual(value, .inappDismiss) + XCTAssertEqual(value, .dismiss) XCTAssertEqual("decisioning.propositionDismiss", value?.toString()) } - func testInAppInteract() throws { + func testInteract() throws { // setup - let value = MessagingEdgeEventType(rawValue: 1) + let value = MessagingEdgeEventType(rawValue: 7) // verify - XCTAssertEqual(value, .inappInteract) + XCTAssertEqual(value, .interact) XCTAssertEqual("decisioning.propositionInteract", value?.toString()) } - func testInAppTrigger() throws { + func testTrigger() throws { // setup - let value = MessagingEdgeEventType(rawValue: 2) + let value = MessagingEdgeEventType(rawValue: 8) // verify - XCTAssertEqual(value, .inappTrigger) + XCTAssertEqual(value, .trigger) XCTAssertEqual("decisioning.propositionTrigger", value?.toString()) } - func testInAppDisplay() throws { + func testDisplay() throws { // setup - let value = MessagingEdgeEventType(rawValue: 3) + let value = MessagingEdgeEventType(rawValue: 9) // verify - XCTAssertEqual(value, .inappDisplay) + XCTAssertEqual(value, .display) XCTAssertEqual("decisioning.propositionDisplay", value?.toString()) } @@ -69,19 +69,80 @@ class MessagingEdgeEventTypeTests: XCTestCase { XCTAssertEqual(MessagingConstants.XDM.Push.EventType.CUSTOM_ACTION, value?.toString()) } + func testInitFromStringDismiss() throws { + // setup + let value = MessagingEdgeEventType(fromType: "decisioning.propositionDismiss") + + // verify + XCTAssertEqual(.dismiss, value) + } + + func testInitFromStringTrigger() throws { + // setup + let value = MessagingEdgeEventType(fromType: "decisioning.propositionTrigger") + + // verify + XCTAssertEqual(.trigger, value) + } + + func testInitFromStringDisplay() throws { + // setup + let value = MessagingEdgeEventType(fromType: "decisioning.propositionDisplay") + + // verify + XCTAssertEqual(.display, value) + } + + func testInitFromStringInteract() throws { + // setup + let value = MessagingEdgeEventType(fromType: "decisioning.propositionInteract") + + // verify + XCTAssertEqual(.interact, value) + } + + func testInitFromStringPushOpen() throws { + // setup + let value = MessagingEdgeEventType(fromType: "pushTracking.applicationOpened") + + // verify + XCTAssertEqual(.pushApplicationOpened, value) + } + + func testInitFromStringPushCustomAction() throws { + // setup + let value = MessagingEdgeEventType(fromType: "pushTracking.customAction") + + // verify + XCTAssertEqual(.pushCustomAction, value) + } + + func testInitFromStringInvalid() throws { + // setup + let value = MessagingEdgeEventType(fromType: "not a valid type") + + // verify + XCTAssertNil(value) + } + func testPropEventTypeDismiss() throws { - XCTAssertEqual("dismiss", MessagingEdgeEventType.inappDismiss.propositionEventType) + XCTAssertEqual("dismiss", MessagingEdgeEventType.dismiss.propositionEventType) } func testPropEventTypeDisplay() throws { - XCTAssertEqual("display", MessagingEdgeEventType.inappDisplay.propositionEventType) + XCTAssertEqual("display", MessagingEdgeEventType.display.propositionEventType) } func testPropEventTypeInteract() throws { - XCTAssertEqual("interact", MessagingEdgeEventType.inappInteract.propositionEventType) + XCTAssertEqual("interact", MessagingEdgeEventType.interact.propositionEventType) } func testPropEventTypeTrigger() throws { - XCTAssertEqual("trigger", MessagingEdgeEventType.inappTrigger.propositionEventType) + XCTAssertEqual("trigger", MessagingEdgeEventType.trigger.propositionEventType) + } + + func testPropEventTypePushCases() throws { + XCTAssertEqual("", MessagingEdgeEventType.pushCustomAction.propositionEventType) + XCTAssertEqual("", MessagingEdgeEventType.pushApplicationOpened.propositionEventType) } } diff --git a/AEPMessaging/Sources/ItemData.swift b/AEPMessaging/Tests/UnitTests/MessagingMigratorTests.swift similarity index 72% rename from AEPMessaging/Sources/ItemData.swift rename to AEPMessaging/Tests/UnitTests/MessagingMigratorTests.swift index 79e68646..1010d767 100644 --- a/AEPMessaging/Sources/ItemData.swift +++ b/AEPMessaging/Tests/UnitTests/MessagingMigratorTests.swift @@ -1,5 +1,5 @@ /* - Copyright 2022 Adobe. All rights reserved. + 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 @@ -11,8 +11,15 @@ */ import Foundation +import XCTest -struct ItemData: Codable { - var id: String? - var content: String +@testable import AEPMessaging +import AEPServices + +class MessagingMigratorTests: XCTestCase { + + override func setUp() { + + } + } diff --git a/AEPMessaging/Tests/UnitTests/MessagingRulesEngine+CachingTests.swift b/AEPMessaging/Tests/UnitTests/MessagingRulesEngine+CachingTests.swift index 94d24c0d..63a6fd6f 100644 --- a/AEPMessaging/Tests/UnitTests/MessagingRulesEngine+CachingTests.swift +++ b/AEPMessaging/Tests/UnitTests/MessagingRulesEngine+CachingTests.swift @@ -14,6 +14,7 @@ @testable import AEPMessaging @testable import AEPRulesEngine @testable import AEPServices +import AEPTestUtils import Foundation import XCTest @@ -35,51 +36,7 @@ class MessagingRulesEngineCachingTests: XCTestCase { mockRuntime = TestableExtensionRuntime() mockRulesEngine = MockLaunchRulesEngine(name: "mockRulesEngine", extensionRuntime: mockRuntime) mockCache = MockCache(name: "mockCache") - messagingRulesEngine = MessagingRulesEngine(extensionRuntime: mockRuntime, rulesEngine: mockRulesEngine, cache: mockCache) - } - - func testLoadCachedPropositionsHappy() throws { - // setup - let aJsonString = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let cacheEntry = CacheEntry(data: aJsonString.data(using: .utf8)!, expiry: .never, metadata: nil) - mockCache.getReturnValue = cacheEntry - - // test - messagingRulesEngine.loadCachedPropositions(for: mockIamSurface) - - // verify - XCTAssertTrue(mockCache.getCalled) - XCTAssertEqual("propositions", mockCache.getParamKey) - XCTAssertTrue(mockRulesEngine.addRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramAddRulesRules?.count) - } - - func testLoadCachedPropositionsWrongScope() throws { - // setup - let aJsonString = JSONFileLoader.getRulesStringFromFile("wrongScopeRule") - let cacheEntry = CacheEntry(data: aJsonString.data(using: .utf8)!, expiry: .never, metadata: nil) - mockCache.getReturnValue = cacheEntry - - // test - messagingRulesEngine.loadCachedPropositions(for: mockIamSurface) - - // verify - XCTAssertTrue(mockCache.getCalled) - XCTAssertEqual("propositions", mockCache.getParamKey) - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadCachedPropositionsNoCacheFound() throws { - // setup - mockCache.getReturnValue = nil - - // test - messagingRulesEngine.loadCachedPropositions(for: mockIamSurface) - - // verify - XCTAssertTrue(mockCache.getCalled) - XCTAssertEqual("propositions", mockCache.getParamKey) - XCTAssertFalse(mockRulesEngine.replaceRulesCalled) + messagingRulesEngine = MessagingRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockRulesEngine, cache: mockCache) } func testCacheRemoteAssetsHappy() throws { @@ -177,46 +134,4 @@ class MessagingRulesEngineCachingTests: XCTestCase { wait(for: [setCalledExpecation], timeout: ASYNC_TIMEOUT) XCTAssertFalse(mockCache.setCalled) } - - func testCachePropositionsAddCache() throws { - // setup - let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let decoder = JSONDecoder() - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.addPropositionsToCache(propositions) - - // verify - XCTAssertTrue(mockCache.setCalled) - XCTAssertEqual("propositions", mockCache.setParamKey) - XCTAssertNotNil(mockCache.setParamEntry) - let cacheEntryData = mockCache.setParamEntry!.data - let cacheString = String(data: cacheEntryData, encoding: .utf8)! - let cachedProps = try decoder.decode([PropositionPayload].self, from: cacheString.data(using: .utf8)!) - XCTAssertEqual(1, cachedProps.count) - XCTAssertEqual(propositions.first?.propositionInfo.id, cachedProps.first?.propositionInfo.id) - XCTAssertEqual(1, messagingRulesEngine.inMemoryPropositionsCount()) - } - - func testCachePropositionsAddCacheThrows() throws { - // setup - let propString = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let decoder = JSONDecoder() - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - mockCache.setShouldThrow = true - - // test - messagingRulesEngine.addPropositionsToCache(propositions) - - // verify - XCTAssertTrue(mockCache.setCalled) - XCTAssertEqual("propositions", mockCache.setParamKey) - XCTAssertNotNil(mockCache.setParamEntry) - let cacheEntryData = mockCache.setParamEntry!.data - let cacheString = String(data: cacheEntryData, encoding: .utf8)! - let cachedProps = try decoder.decode([PropositionPayload].self, from: cacheString.data(using: .utf8)!) - XCTAssertEqual(1, cachedProps.count) - XCTAssertEqual(propositions.first?.propositionInfo.id, cachedProps.first?.propositionInfo.id) - } } diff --git a/AEPMessaging/Tests/UnitTests/MessagingRulesEngineTests.swift b/AEPMessaging/Tests/UnitTests/MessagingRulesEngineTests.swift index 13dfa47e..74afa3cb 100644 --- a/AEPMessaging/Tests/UnitTests/MessagingRulesEngineTests.swift +++ b/AEPMessaging/Tests/UnitTests/MessagingRulesEngineTests.swift @@ -14,6 +14,7 @@ @testable import AEPMessaging @testable import AEPRulesEngine @testable import AEPServices +import AEPTestUtils import Foundation import XCTest @@ -28,7 +29,7 @@ class MessagingRulesEngineTests: XCTestCase { mockRuntime = TestableExtensionRuntime() mockRulesEngine = MockLaunchRulesEngine(name: "mockRulesEngine", extensionRuntime: mockRuntime) mockCache = MockCache(name: "mockCache") - messagingRulesEngine = MessagingRulesEngine(extensionRuntime: mockRuntime, rulesEngine: mockRulesEngine, cache: mockCache) + messagingRulesEngine = MessagingRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockRulesEngine, cache: mockCache) } func testInitializer() throws { @@ -39,11 +40,11 @@ class MessagingRulesEngineTests: XCTestCase { try? cache.set(key: "propositions", entry: cacheEntry) // test - let mre = MessagingRulesEngine(name: "mockRE", extensionRuntime: TestableExtensionRuntime()) + let mre = MessagingRulesEngine(name: "mockRE", extensionRuntime: TestableExtensionRuntime(), cache: mockCache) // verify XCTAssertNotNil(mre.runtime) - XCTAssertNotNil(mre.rulesEngine) + XCTAssertNotNil(mre.launchRulesEngine) XCTAssertNotNil(mre.cache) } @@ -58,235 +59,94 @@ class MessagingRulesEngineTests: XCTestCase { XCTAssertTrue(mockRulesEngine.processCalled) XCTAssertEqual(event, mockRulesEngine.paramProcessedEvent) } +} - func testLoadPropositionsHappy() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) - - // verify - XCTAssertTrue(mockRulesEngine.addRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramAddRulesRules?.count) - XCTAssertTrue(mockCache.setCalled) +class LaunchRulesEngineMessagingTests: XCTestCase { + var launchRulesEngine: MockLaunchRulesEngine! + var mockRuntime: TestableExtensionRuntime! + + override func setUp() { + mockRuntime = TestableExtensionRuntime() + launchRulesEngine = MockLaunchRulesEngine(name: "mockLaunchRulesEngine", extensionRuntime: mockRuntime) } - func testLoadPropositionsDefaultSavesToPersitence() throws { + func testLoadRulesHappy() throws { + // setup let decoder = JSONDecoder() let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - + let ruleString = propositions.first?.items.first?.itemData["content"] as? String + let rulesArray = JSONRulesParser.parse(ruleString?.data(using: .utf8) ?? Data(), runtime: mockRuntime) ?? [] + // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, expectedScope: mockIamSurface) + launchRulesEngine.replaceRules(with: rulesArray) // verify - XCTAssertTrue(mockCache.setCalled) - } - - func testLoadPropositionsClearExisting() throws { + XCTAssertTrue(launchRulesEngine.replaceRulesCalled) + XCTAssertEqual(1, launchRulesEngine.paramReplaceRulesRules?.count) + } + + func testLoadRulesClearExisting() throws { // setup let decoder = JSONDecoder() let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: true, expectedScope: mockIamSurface) + let ruleString = propositions.first?.items.first?.itemData["content"] as? String + let rulesArray = JSONRulesParser.parse(ruleString?.data(using: .utf8) ?? Data(), runtime: mockRuntime) ?? [] - // verify - XCTAssertTrue(mockRulesEngine.replaceRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramRules?.count) - } - - func testLoadPropositionsMismatchedScope() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("wrongScopeRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) + launchRulesEngine.replaceRules(with: rulesArray) // verify - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadPropositionsEmptyStringContent() throws { + XCTAssertTrue(launchRulesEngine.replaceRulesCalled) + XCTAssertEqual(1, launchRulesEngine.paramReplaceRulesRules?.count) + } + + func testLoadRulesEmptyStringContent() throws { // setup let decoder = JSONDecoder() let propString: String = JSONFileLoader.getRulesStringFromFile("emptyContentStringRule") let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) + let ruleString = propositions.first?.items.first?.itemData["content"] as? String + let rulesArray = JSONRulesParser.parse(ruleString?.data(using: .utf8) ?? Data(), runtime: mockRuntime) ?? [] - // verify - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadPropositionsMalformedContent() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("malformedContentRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) + launchRulesEngine.replaceRules(with: rulesArray) // verify - XCTAssertFalse(mockRulesEngine.addRulesCalled) + XCTAssertFalse(launchRulesEngine.addRulesCalled) } - - func testLoadPropositionsEmptyRuleString() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("wrongScopeRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) - // verify - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadPropositionsNoItemsInPayload() throws { - // setup - let propInfo = PropositionInfo(id: "a", scope: "a", scopeDetails: [:]) - let propPayload = PropositionPayload(propositionInfo: propInfo, items: []) - - // test - messagingRulesEngine.loadPropositions([propPayload], clearExisting: false, expectedScope: mockIamSurface) - - // verify - XCTAssertFalse(mockRulesEngine.replaceRulesCalled) - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadPropositionsEmptyContentInPayload() throws { - // setup - let itemData = ItemData(content: "") - let payloadItem = PayloadItem(data: itemData) - let propInfo = PropositionInfo(id: "a", scope: "a", scopeDetails: [:]) - let propPayload = PropositionPayload(propositionInfo: propInfo, items: [payloadItem]) - - // test - messagingRulesEngine.loadPropositions([propPayload], clearExisting: false, expectedScope: mockIamSurface) - - // verify - XCTAssertFalse(mockRulesEngine.replaceRulesCalled) - XCTAssertFalse(mockRulesEngine.addRulesCalled) - } - - func testLoadPropositionsEventSequence() throws { + func testLoadRulesMalformedContent() throws { // setup let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("eventSequenceRule") + let propString: String = JSONFileLoader.getRulesStringFromFile("malformedContentRule") let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, expectedScope: mockIamSurface) - - // verify - XCTAssertTrue(mockRulesEngine.addRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramAddRulesRules?.count) - } - - func testLoadPropositionsEmptyPropositions() throws { - // setup - let propositions: [PropositionPayload] = [] + let ruleString = propositions.first?.items.first?.itemData["content"] as? String + let rulesArray = JSONRulesParser.parse(ruleString?.data(using: .utf8) ?? Data(), runtime: mockRuntime) ?? [] // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: true, expectedScope: mockIamSurface) + launchRulesEngine.replaceRules(with: rulesArray) // verify - XCTAssertTrue(mockRulesEngine.replaceRulesCalled) - XCTAssertEqual(0, mockRulesEngine.paramRules?.count) + XCTAssertFalse(launchRulesEngine.addRulesCalled) } - - func testLoadPropositionsExistingReplacedWithEmpty() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, expectedScope: mockIamSurface) - // verify - XCTAssertTrue(mockRulesEngine.addRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramAddRulesRules?.count) - XCTAssertEqual(1, messagingRulesEngine.propositionInfoCount()) - XCTAssertEqual(1, messagingRulesEngine.inMemoryPropositionsCount()) - - // test - messagingRulesEngine.loadPropositions(nil, clearExisting: true, persistChanges: true, expectedScope: mockIamSurface) - - // verify - XCTAssertTrue(mockRulesEngine.replaceRulesCalled) - XCTAssertEqual(0, mockRulesEngine.paramRules?.count) - XCTAssertEqual(0, messagingRulesEngine.propositionInfoCount()) - XCTAssertEqual(0, messagingRulesEngine.inMemoryPropositionsCount()) - } - - func testLoadPropositionsExistingNoReplacedWithEmpty() throws { + func testLoadRulesEventSequence() throws { // setup let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + let propString: String = JSONFileLoader.getRulesStringFromFile("eventSequenceRule") let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, expectedScope: mockIamSurface) + let ruleString = propositions.first?.items.first?.itemData["content"] as? String + let rulesArray = JSONRulesParser.parse(ruleString?.data(using: .utf8) ?? Data(), runtime: mockRuntime) ?? [] - // verify - XCTAssertTrue(mockRulesEngine.addRulesCalled) - XCTAssertEqual(1, mockRulesEngine.paramAddRulesRules?.count) - XCTAssertEqual(1, messagingRulesEngine.propositionInfoCount()) - XCTAssertEqual(1, messagingRulesEngine.inMemoryPropositionsCount()) - - // test - messagingRulesEngine.loadPropositions(nil, clearExisting: false, persistChanges: true, expectedScope: mockIamSurface) - - // verify - XCTAssertEqual(1, messagingRulesEngine.propositionInfoCount()) - XCTAssertEqual(1, messagingRulesEngine.inMemoryPropositionsCount()) - } - - func testLoadPropositionsDoNotPersistChanges() throws { - // setup - let decoder = JSONDecoder() - let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") - let propositions = try decoder.decode([PropositionPayload].self, from: propString.data(using: .utf8)!) - - // test - messagingRulesEngine.loadPropositions(propositions, clearExisting: false, persistChanges: false, expectedScope: mockIamSurface) - // verify - XCTAssertFalse(mockCache.setCalled) - } - - func testPropositionInfoForMessageIdHappy() throws { - // setup - messagingRulesEngine.propositionInfo["id"] = PropositionInfo(id: "pid", scope: "scope", scopeDetails: [:]) - // test - let propInfo = messagingRulesEngine.propositionInfoForMessageId("id") - - // verify - XCTAssertNotNil(propInfo) - XCTAssertEqual("pid", propInfo?.id) - XCTAssertEqual("scope", propInfo?.scope) - XCTAssertEqual(0, propInfo?.scopeDetails.count) - } - - func testPropositionInfoForMessageIdNoMatch() throws { - // test - let propInfo = messagingRulesEngine.propositionInfoForMessageId("good luck finding a message with this id. ha!") - + launchRulesEngine.replaceRules(with: rulesArray) + // verify - XCTAssertNil(propInfo) + XCTAssertTrue(launchRulesEngine.replaceRulesCalled) + XCTAssertEqual(1, launchRulesEngine.paramReplaceRulesRules?.count) } } diff --git a/AEPMessaging/Tests/UnitTests/MessagingTests.swift b/AEPMessaging/Tests/UnitTests/MessagingTests.swift index 58c1541f..3397f170 100644 --- a/AEPMessaging/Tests/UnitTests/MessagingTests.swift +++ b/AEPMessaging/Tests/UnitTests/MessagingTests.swift @@ -12,34 +12,41 @@ import AEPCore import AEPServices +import AEPTestUtils import XCTest @testable import AEPMessaging class MessagingTests: XCTestCase { + static let EXPERIENCE_CLOUD_ORG = "experienceCloud.org" + var messaging: Messaging! var mockRuntime: TestableExtensionRuntime! var mockNetworkService: MockNetworkService? var mockMessagingRulesEngine: MockMessagingRulesEngine! + var mockFeedRulesEngine: MockFeedRulesEngine! var mockLaunchRulesEngine: MockLaunchRulesEngine! var mockCache: MockCache! - let mockIamSurface = "mobileapp://com.apple.dt.xctest.tool" + let mockFeedSurface = Surface(path: "promos/feed1") // Mock constants let MOCK_ECID = "mock_ecid" let MOCK_EVENT_DATASET = "mock_event_dataset" let MOCK_EXP_ORG_ID = "mock_exp_org_id" let MOCK_PUSH_TOKEN = "mock_pushToken" - + let EXPERIENCE_CLOUD_ORG = "experienceCloud.org" + // before each override func setUp() { mockRuntime = TestableExtensionRuntime() mockCache = MockCache(name: "mockCache") mockLaunchRulesEngine = MockLaunchRulesEngine(name: "mockLaunchRulesEngine", extensionRuntime: mockRuntime) - mockMessagingRulesEngine = MockMessagingRulesEngine(extensionRuntime: mockRuntime, rulesEngine: mockLaunchRulesEngine, cache: mockCache) - messaging = Messaging(runtime: mockRuntime, rulesEngine: mockMessagingRulesEngine, expectedScope: mockIamSurface) + mockFeedRulesEngine = MockFeedRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngine) + mockMessagingRulesEngine = MockMessagingRulesEngine(extensionRuntime: mockRuntime, launchRulesEngine: mockLaunchRulesEngine, cache: mockCache) + + messaging = Messaging(runtime: mockRuntime, rulesEngine: mockMessagingRulesEngine, feedRulesEngine: mockFeedRulesEngine, expectedSurfaceUri: mockFeedSurface.uri, cache: mockCache) messaging.onRegistered() - + mockNetworkService = MockNetworkService() ServiceProvider.shared.networkService = mockNetworkService! @@ -49,198 +56,384 @@ class MessagingTests: XCTestCase { override func tearDown() { MobileCore.messagingDelegate = nil } - + /// validate the extension is registered without any error func testRegisterExtension_registersWithoutAnyErrorOrCrash() { XCTAssertNoThrow(MobileCore.registerExtensions([Messaging.self])) } - + /// validate that 5 listeners are registered onRegister func testOnRegistered_fiveListenersAreRegistered() { - XCTAssertEqual(mockRuntime.listeners.count, 5) + XCTAssertEqual(mockRuntime.listeners.count, 6) } - + func testOnUnregisteredCallable() throws { messaging.onUnregistered() } - + func testReadyForEventHappy() throws { // setup let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test let result = messaging.readyForEvent(event) - + // verify XCTAssertTrue(result) } - + func testReadyForEventNoConfigurationSharedState() throws { // setup let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test let result = messaging.readyForEvent(event) - + // verify XCTAssertFalse(result) } - + func testReadyForEventNoIdentitySharedState() throws { // setup let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) - + // test let result = messaging.readyForEvent(event) - + // verify XCTAssertFalse(result) } - + func testHandleWildcardEvent() throws { // setup let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) - + // test mockRuntime.simulateComingEvents(event) - + // verify XCTAssertTrue(mockMessagingRulesEngine.processCalled) XCTAssertEqual(event, mockMessagingRulesEngine.paramProcessEvent) } - - func testFetchMessages() throws { + +// func testFetchMessages() throws { +// // setup +// let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) +// mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: "aTestOrgId"], status: SharedStateStatus.set)) +// mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) +// +// // test +// _ = messaging.readyForEvent(event) +// +// // verify +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let fetchEvent = mockRuntime.firstEvent +// XCTAssertNotNil(fetchEvent) +// XCTAssertEqual(EventType.edge, fetchEvent?.type) +// XCTAssertEqual(EventSource.requestContent, fetchEvent?.source) +// let fetchEventData = fetchEvent?.data +// XCTAssertNotNil(fetchEventData) +// let fetchEventQuery = fetchEventData?[MessagingConstants.XDM.Inbound.Key.QUERY] as? [String: Any] +// XCTAssertNotNil(fetchEventQuery) +// let fetchEventPersonalization = fetchEventQuery?[MessagingConstants.XDM.Inbound.Key.PERSONALIZATION] as? [String: Any] +// XCTAssertNotNil(fetchEventPersonalization) +// let fetchEventSurfaces = fetchEventPersonalization?[MessagingConstants.XDM.Inbound.Key.SURFACES] as? [String] +// XCTAssertNotNil(fetchEventSurfaces) +// XCTAssertEqual(1, fetchEventSurfaces?.count) +// XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool", fetchEventSurfaces?.first) +// } + +// func testFetchMessages_whenUpdateFeedsRequest() throws { +// // setup +// let event = Event(name: "Update propositions", +// type: "com.adobe.eventType.messaging", +// source: "com.adobe.eventSource.requestContent", +// data: [ +// "updatepropositions": true, +// "surfaces": [ +// [ "uri": mockFeedSurface.uri ] +// ] +// ]) +// mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: "aTestOrgId"], status: SharedStateStatus.set)) +// mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) +// +// // test +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) +// let fetchEvent = mockRuntime.firstEvent +// XCTAssertNotNil(fetchEvent) +// XCTAssertEqual(EventType.edge, fetchEvent?.type) +// XCTAssertEqual(EventSource.requestContent, fetchEvent?.source) +// let fetchEventData = fetchEvent?.data +// XCTAssertNotNil(fetchEventData) +// let fetchEventQuery = fetchEventData?[MessagingConstants.XDM.Inbound.Key.QUERY] as? [String: Any] +// XCTAssertNotNil(fetchEventQuery) +// let fetchEventPersonalization = fetchEventQuery?[MessagingConstants.XDM.Inbound.Key.PERSONALIZATION] as? [String: Any] +// XCTAssertNotNil(fetchEventPersonalization) +// let fetchEventSurfaces = fetchEventPersonalization?[MessagingConstants.XDM.Inbound.Key.SURFACES] as? [String] +// XCTAssertNotNil(fetchEventSurfaces) +// XCTAssertEqual(1, fetchEventSurfaces?.count) +// XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool/promos/feed1", fetchEventSurfaces?.first) +// } + + func testFetchMessages_whenUpdateFeedsRequest_emptySurfacesInArray() throws { // setup - let event = Event(name: "Test Event Name", type: "type", source: "source", data: nil) - mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: "aTestOrgId"], status: SharedStateStatus.set)) + let event = Event(name: "Update message feeds event", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: [ + "updatefeeds": true, + "surfaces": [ + "", + "" + ] + ]) + mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [EXPERIENCE_CLOUD_ORG: "aTestOrgId"], status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - - // test - _ = messaging.readyForEvent(event) - - // verify - XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) - let fetchEvent = mockRuntime.firstEvent - XCTAssertNotNil(fetchEvent) - XCTAssertEqual(EventType.edge, fetchEvent?.type) - XCTAssertEqual(EventSource.requestContent, fetchEvent?.source) - let fetchEventData = fetchEvent?.data - XCTAssertNotNil(fetchEventData) - let fetchEventQuery = fetchEventData?[MessagingConstants.XDM.IAM.Key.QUERY] as? [String: Any] - XCTAssertNotNil(fetchEventQuery) - let fetchEventPersonalization = fetchEventQuery?[MessagingConstants.XDM.IAM.Key.PERSONALIZATION] as? [String: Any] - XCTAssertNotNil(fetchEventPersonalization) - let fetchEventSurfaces = fetchEventPersonalization?[MessagingConstants.XDM.IAM.Key.SURFACES] as? [String] - XCTAssertNotNil(fetchEventSurfaces) - XCTAssertEqual(1, fetchEventSurfaces?.count) - XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool", fetchEventSurfaces?.first) - } - - func testHandleEdgePersonalizationNotificationHappy() throws { - // setup - let event = Event(name: "Test Offer Notification Event", type: EventType.edge, - source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) - - // test - mockRuntime.simulateComingEvents(event) - - // verify - XCTAssertTrue(mockMessagingRulesEngine.loadPropositionsCalled) - let loadedRules = mockMessagingRulesEngine.paramLoadPropositionsPropositions - XCTAssertNotNil(loadedRules) - XCTAssertNotNil(loadedRules?.first) - XCTAssertEqual(false, mockMessagingRulesEngine.paramLoadPropositionsClearExisting) - XCTAssertEqual(true, mockMessagingRulesEngine.paramLoadPropositionsPersistChanges) - } - - func testHandleEdgePersonalizationNotificationEmptyPayload() throws { - // setup - let eventData = getOfferEventData(items: [:]) - let event = Event(name: "Test Offer Notification Event", type: EventType.edge, - source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: eventData) - + // test mockRuntime.simulateComingEvents(event) - + // verify - XCTAssertTrue(mockMessagingRulesEngine.loadPropositionsCalled) - XCTAssertEqual(0, mockMessagingRulesEngine.paramLoadPropositionsPropositions?.count) - XCTAssertEqual(false, mockMessagingRulesEngine.paramLoadPropositionsClearExisting) - XCTAssertEqual(true, mockMessagingRulesEngine.paramLoadPropositionsPersistChanges) + // TODO: verify push status event response? + XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) } - func testHandleEdgePersonalizationNotificationNewRequestEvent() throws { + func testFetchMessages_whenUpdateFeedsRequest_emptySurfacesArray() throws { // setup - messaging.setLastProcessedRequestEventId("oldEventId") - let event = Event(name: "Test Offer Notification Event", type: EventType.edge, - source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) - + let event = Event(name: "Update message feeds event", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: [ + "updatefeeds": true, + "surfaces": [] as [String] + ]) + mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [EXPERIENCE_CLOUD_ORG: "aTestOrgId"], status: SharedStateStatus.set)) + mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) + // test mockRuntime.simulateComingEvents(event) - + // verify - XCTAssertTrue(mockMessagingRulesEngine.loadPropositionsCalled) - XCTAssertEqual(true, mockMessagingRulesEngine.paramLoadPropositionsClearExisting) - XCTAssertEqual(true, mockMessagingRulesEngine.paramLoadPropositionsPersistChanges) + // TODO: verify push status event response? + XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) } - func testHandleEdgePersonalizationNotificationRequestEventDoesNotMatch() throws { - // setup - messaging.setMessagesRequestEventId("requestEventId") - let event = Event(name: "Test Offer Notification Event", type: EventType.edge, - source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) - - // test - mockRuntime.simulateComingEvents(event) - - // verify - XCTAssertFalse(mockMessagingRulesEngine.loadPropositionsCalled) - } - +// func testHandleEdgePersonalizationNotificationHappy_inAppPropositions() throws { +// // setup +// messaging.setMessagesRequestEventId("mockRequestEventId") +// messaging.setLastProcessedRequestEventId("mockRequestEventId") +// messaging.setRequestedSurfacesforEventId("mockRequestEventId", expectedSurfaces: [Surface(uri: "mobileapp://com.apple.dt.xctest.tool")]) +// let event = Event(name: "Test Offer Notification Event", type: EventType.edge, +// source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) +// +// // test +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount(), "in-app propositions should not be cached") +// XCTAssertEqual(2, messaging.propositionInfoCount()) +// XCTAssertTrue(mockLaunchRulesEngine.addRulesCalled) +// XCTAssertEqual(2, mockLaunchRulesEngine.paramAddRulesRules?.count) +// XCTAssertTrue(mockCache.setCalled) +// } +// +// func testHandleEdgePersonalizationNotificationEmptyPayload() throws { +// // setup +// messaging.setMessagesRequestEventId("mockRequestEventId") +// messaging.setLastProcessedRequestEventId("mockRequestEventId") +// messaging.setRequestedSurfacesforEventId("mockRequestEventId", expectedSurfaces: [Surface(uri: "mobileapp://com.apple.dt.xctest.tool")]) +// let eventData = getOfferEventData(items: [[:]]) +// let event = Event(name: "Test Offer Notification Event", type: EventType.edge, +// source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: eventData) +// +// // test +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) +// XCTAssertEqual(0, messaging.propositionInfoCount()) +// XCTAssertFalse(mockLaunchRulesEngine.addRulesCalled) +// XCTAssertFalse(mockLaunchRulesEngine.replaceRulesCalled) +// XCTAssertFalse(mockCache.setCalled) +// } +// +// func testHandleEdgePersonalizationNotificationNewRequestEvent() throws { +// // setup +// messaging.setLastProcessedRequestEventId("oldEventId") +// messaging.setMessagesRequestEventId("mockRequestEventId") +// messaging.setRequestedSurfacesforEventId("mockRequestEventId", expectedSurfaces: [Surface(uri: "mobileapp://com.apple.dt.xctest.tool")]) +// let event = Event(name: "Test Offer Notification Event", type: EventType.edge, +// source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) +// +// // test +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) +// XCTAssertEqual(2, messaging.propositionInfoCount()) +// XCTAssertTrue(mockLaunchRulesEngine.replaceRulesCalled) +// XCTAssertEqual(2, mockLaunchRulesEngine.paramReplaceRulesRules?.count) +// XCTAssertTrue(mockCache.setCalled) +// } +// +// func testHandleEdgePersonalizationNotificationRequestEventDoesNotMatch() throws { +// // setup +// messaging.setMessagesRequestEventId("someRequestEventId") +// let event = Event(name: "Test Offer Notification Event", type: EventType.edge, +// source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData()) +// +// // test +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) +// XCTAssertEqual(0, messaging.propositionInfoCount()) +// XCTAssertFalse(mockLaunchRulesEngine.replaceRulesCalled) +// XCTAssertFalse(mockLaunchRulesEngine.addRulesCalled) +// XCTAssertFalse(mockCache.setCalled) +// } +// +// +// func testHandleEdgePersonalizationNotification_SurfacesInPersonlizationNotificationDoNotExistInRequestedSurfacesForEvent() throws { +// // setup +// let aJsonRule = JSONFileLoader.getRulesStringFromFile("showOnceRule") +// let jsonEntry = "{\"mobileapp://com.apple.dt.xctest.tool\":\(aJsonRule)}" +// let cacheEntry = CacheEntry(data: jsonEntry.data(using: .utf8)!, expiry: .never, metadata: nil) +// mockCache.getReturnValue = cacheEntry +// let event = Event(name: "Test Offer Notification Event", type: EventType.edge, +// source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData(surface: "someScope")) +// messaging.setLastProcessedRequestEventId("mockRequestEventId") +// messaging.setMessagesRequestEventId("mockRequestEventId") +// messaging.setRequestedSurfacesforEventId("mockRequestEventId", expectedSurfaces: [Surface(uri: "mobileapp://com.apple.dt.xctest.tool")]) +// +// // test +// XCTAssertEqual(true, mockCache.propositions?.contains { $0.key.uri == "mobileapp://com.apple.dt.xctest.tool" }) +// mockRuntime.simulateComingEvents(event) +// +// // verify +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) +// XCTAssertEqual(0, messaging.propositionInfoCount()) +// // previous cache should be removed +// XCTAssertTrue(mockCache.removeCalled) +// XCTAssertEqual(MessagingConstants.Caches.PROPOSITIONS, mockCache.removeParamKey) +// +// XCTAssertFalse(mockLaunchRulesEngine.replaceRulesCalled) +// XCTAssertFalse(mockLaunchRulesEngine.addRulesCalled) +// +// } + + // func testHandleEdgePersonalizationFeedsNotificationHappy() throws { + // // setup + // messaging.setMessagesRequestEventId("mockRequestEventId") + // messaging.setLastProcessedRequestEventId("mockRequestEventId") + // messaging.setRequestedSurfacesforEventId("mockRequestEventId", expectedSurfaces: [Surface(uri: "mobileapp://com.apple.dt.xctest.tool/promos/feed1")]) + // mockLaunchRulesEngine.ruleConsequences.removeAll() + // mockLaunchRulesEngine.ruleConsequences = [RuleConsequence(id: "someId", type: "cjmiam", details: [ + // "mobileParameters": [ + // "id": "5c2ec561-49dd-4c8d-80bb-1fd67f6fca5d", + // "title": "Flash sale!", + // "body": "All winter gear is now up to 30% off at checkout.", + // "imageUrl": "https://luma.com/wintersale.png", + // "actionUrl": "https://luma.com/sale", + // "actionTitle": "Shop the sale!", + // "publishedDate": 1680568056, + // "expiryDate": 1712190456, + // "meta": [ + // "feedName":"Winter Promo", + // "surface":"mobileapp://com.apple.dt.xctest.tool/promos/feed1" + // ], + // "type": "messagefeed" + // ] as [String: Any] + // ])] + // + // let event = Event(name: "Test Offer Notification Event", type: EventType.edge, + // source: MessagingConstants.Event.Source.PERSONALIZATION_DECISIONS, data: getOfferEventData(items:[["data": ["content": mockFeedContent]]], surface:"mobileapp://com.apple.dt.xctest.tool/promos/feed1")) + // + // // test + // mockRuntime.simulateComingEvents(event) + // + // // verify + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertEqual(0, messaging.propositionInfoCount()) + // XCTAssertTrue(mockLaunchRulesEngine.addRulesCalled) + // XCTAssertFalse(mockLaunchRulesEngine.replaceRulesCalled) + // XCTAssertFalse(mockCache.setCalled) + // + // XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) + // let dispatchedEvent = mockRuntime.dispatchedEvents.first + // + // XCTAssertEqual("com.adobe.eventType.messaging", dispatchedEvent?.type) + // XCTAssertEqual("com.adobe.eventSource.notification", dispatchedEvent?.source) + // + // let propositionsArray = dispatchedEvent?.propositions + // XCTAssertNotNil(propositionsArray) + // XCTAssertEqual(1, propositionsArray?.count) + // let feed = propositionsArray?.first as? Feed + // XCTAssertEqual("Winter Promo", feed?.name) + // XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool/promos/feed1", feed?.surface.uri) + // XCTAssertEqual(1, feed?.items.count) + // XCTAssertEqual("Flash sale!", feed?.items.first?.title) + // XCTAssertEqual("All winter gear is now up to 30% off at checkout.", feed?.items.first?.body) + // XCTAssertEqual("https://luma.com/wintersale.png", feed?.items.first?.imageUrl) + // XCTAssertEqual("https://luma.com/sale", feed?.items.first?.actionUrl) + // XCTAssertEqual("Shop the sale!", feed?.items.first?.actionTitle) + // XCTAssertEqual(1680568056, feed?.items.first?.inbound?.publishedDate) + // XCTAssertEqual(1712190456, feed?.items.first?.inbound?.expiryDate) + // XCTAssertNotNil(feed?.items.first?.inbound?.meta) + // XCTAssertEqual(2, feed?.items.first?.inbound?.meta?.count) + // XCTAssertEqual("Winter Promo", feed?.items.first?.inbound?.meta?["feedName"] as? String) + // XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool/promos/feed1", feed?.items.first?.inbound?.meta?["surface"] as? String) + // XCTAssertNotNil(propositionsArray?.first?.scopeDetails) + // XCTAssertEqual(0, propositionsArray?.first?.scopeDetails.count) + // } + func testHandleRulesResponseNoHtml() throws { // setup - mockMessagingRulesEngine.propositionInfoForMessageIdReturnValue = PropositionInfo(id: "id", scope: "scope", scopeDetails: [:]) + messaging.propositionInfo["mockMessageId"] = PropositionInfo(id: "id", scope: "scope", scopeDetails: [:]) let event = Event(name: "Test Rules Engine Response Event", type: EventType.rulesEngine, source: EventSource.responseContent, data: getRulesResponseEventData(html: nil)) - - let expectation = XCTestExpectation(description: "shouldShowMessage was called in delegate") - expectation.isInverted = true - let delegate = TestableMessagingDelegate(expectation: expectation) - MobileCore.messagingDelegate = delegate - - // test - mockRuntime.simulateComingEvents(event) - - // verify - wait(for: [expectation], timeout: 1.0) - XCTAssertFalse(delegate.shouldShowMessageCalled) - } - - func testHandleRulesResponseNoPropositionInfoForMessage() throws { - // setup - let event = Event(name: "Test Rules Engine Response Event", - type: EventType.rulesEngine, - source: EventSource.responseContent, - data: getRulesResponseEventData()) - + let expectation = XCTestExpectation(description: "shouldShowMessage was called in delegate") expectation.isInverted = true let delegate = TestableMessagingDelegate(expectation: expectation) MobileCore.messagingDelegate = delegate - + // test mockRuntime.simulateComingEvents(event) - + // verify wait(for: [expectation], timeout: 1.0) XCTAssertFalse(delegate.shouldShowMessageCalled) } + + // func testHandleRulesResponseNoPropositionInfoForMessage() throws { + // // setup + // let event = Event(name: "Test Rules Engine Response Event", + // type: EventType.rulesEngine, + // source: EventSource.responseContent, + // data: getRulesResponseEventData()) + // + // let expectation = XCTestExpectation(description: "shouldShowMessage was called in delegate") + // expectation.isInverted = true + // let delegate = TestableMessagingDelegate(expectation: expectation) + // MobileCore.messagingDelegate = delegate + // + // // test + // mockRuntime.simulateComingEvents(event) + // + // // verify + // wait(for: [expectation], timeout: 1.0) + // XCTAssertFalse(delegate.shouldShowMessageCalled) + // } func testHandleRulesResponseNilData() throws { // setup @@ -248,20 +441,20 @@ class MessagingTests: XCTestCase { type: EventType.rulesEngine, source: EventSource.responseContent, data: nil) - + let expectation = XCTestExpectation(description: "shouldShowMessage was called in delegate") expectation.isInverted = true let delegate = TestableMessagingDelegate(expectation: expectation) MobileCore.messagingDelegate = delegate - + // test mockRuntime.simulateComingEvents(event) - + // verify wait(for: [expectation], timeout: 1.0) XCTAssertFalse(delegate.shouldShowMessageCalled) } - + func testHandleRulesResponseNoHtmlInData() throws { // setup let event = Event(name: "Test Rules Engine Response Event", @@ -273,28 +466,28 @@ class MessagingTests: XCTestCase { expectation.isInverted = true let delegate = TestableMessagingDelegate(expectation: expectation) MobileCore.messagingDelegate = delegate - + // test mockRuntime.simulateComingEvents(event) - + // verify wait(for: [expectation], timeout: 1.0) XCTAssertFalse(delegate.shouldShowMessageCalled) } - + /// validating handleProcessEvent func testHandleProcessEvent_SetPushIdentifierEvent_Happy() { let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) - + // verify that shared state is created XCTAssertEqual(MOCK_PUSH_TOKEN, mockRuntime.firstSharedState?[MessagingConstants.SharedState.Messaging.PUSH_IDENTIFIER] as! String) - + // verify the dispatched edge event guard let edgeEvent = mockRuntime.firstEvent else { XCTFail() @@ -303,7 +496,7 @@ class MessagingTests: XCTestCase { XCTAssertEqual("Push notification profile edge event", edgeEvent.name) XCTAssertEqual(EventType.edge, edgeEvent.type) XCTAssertEqual(EventSource.requestContent, edgeEvent.source) - + // verify event data let flattenEdgeEvent = edgeEvent.data?.flattening() let pushNotification = flattenEdgeEvent?["data.pushNotificationDetails"] as? [[String: Any]] @@ -316,22 +509,22 @@ class MessagingTests: XCTestCase { XCTAssertEqual("ECID", flattenedPushNotification?["identity.namespace.code"] as? String) XCTAssertEqual("apns", flattenedPushNotification?["platform"] as? String) } - + /// validating handleProcessEvent withNilData func testHandleProcessEvent_withNilEventData() { let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: nil) XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with no shared state func testHandleProcessEvent_NoSharedState() { let eventData: [String: Any] = [:] let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with empty shared state func testHandleProcessEvent_withEmptySharedState() { let eventData: [String: Any] = [:] @@ -339,60 +532,60 @@ class MessagingTests: XCTestCase { mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: nil, status: SharedStateStatus.set)) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: nil, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: nil, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with invalid config func testhandleProcessEvent_withInvalidConfig() { let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: [:]) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with empty token func testhandleProcessEvent_withEmptyToken() { let mockConfig = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: ""] - + let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: [:]) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertEqual(0, mockRuntime.dispatchedEvents.count, "push token event should not be dispatched") } - + /// validating handleProcessEvent with working shared state and data func testHandleProcessEvent_withNoIdentityData() { - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] - + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] + let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] - + let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: nil, status: SharedStateStatus.none)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertEqual(0, mockRuntime.dispatchedEvents.count, "push token event should not be dispatched") } - + /// validating handleProcessEvent with working shared state and data - + func testhandleProcessEvent_withConfigAndIdentityData() { - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] - + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] + let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] - + let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertNotNil(mockRuntime.dispatchedEvents) @@ -400,51 +593,51 @@ class MessagingTests: XCTestCase { XCTAssertEqual(EventType.edge, pushTokenEvent?.type) XCTAssertEqual(EventSource.requestContent, pushTokenEvent?.source) } - + /// validating handleProcessEvent with working apns sandbox func testHandleProcessEvent_withApnsSandbox() { - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID, + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID, MessagingConstants.SharedState.Configuration.USE_SANDBOX: true] as [String: Any] - + let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] - + let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with working apns sandbox func testHandleProcessEvent_withApns() { - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID, + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID, MessagingConstants.SharedState.Configuration.USE_SANDBOX: false] as [String: Any] - + let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] - + let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } - + /// validating handleProcessEvent with Tracking info event when event data is empty func testHandleProcessEvent_withTrackingInfoEvent() { let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_EVENT_DATASET: MOCK_EVENT_DATASET] as [String: Any] let mockEdgeIdentity = [MessagingConstants.SharedState.EdgeIdentity.IDENTITY_MAP: [MessagingConstants.SharedState.EdgeIdentity.ECID: [[MessagingConstants.SharedState.EdgeIdentity.ID: MOCK_ECID]]]] - + let eventData: [String: Any]? = [ MessagingConstants.Event.Data.Key.EVENT_TYPE: "testEventType", - MessagingConstants.Event.Data.Key.MESSAGE_ID: "testMessageId" + MessagingConstants.Event.Data.Key.ID: "testMessageId" ] - - let event = Event(name: "trackingInfo", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: eventData) + + let event = Event(name: "trackingInfo", type: EventType.messaging, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: mockEdgeIdentity, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertEqual(2, mockRuntime.dispatchedEvents.count) @@ -457,51 +650,360 @@ class MessagingTests: XCTestCase { XCTAssertEqual(EventType.edge, dispatchedEdgeEvent?.type) XCTAssertEqual(EventSource.requestContent, dispatchedEdgeEvent?.source) } - + func testHandleProcessEventRefreshMessageEvent() throws { // setup - let event = Event(name: "handleProcessEvent", type: MessagingConstants.Event.EventType.messaging, source: EventSource.requestContent, data: [ + let event = Event(name: "handleProcessEvent", type: EventType.messaging, source: EventSource.requestContent, data: [ MessagingConstants.Event.Data.Key.REFRESH_MESSAGES: true ]) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) } + + // func testHandleProcessEventUpdateFeedsEvent() throws { + // // setup + // let event = Event(name: "Update message feeds event", + // type: MessagingConstants.Event.EventType.messaging, + // source: EventSource.requestContent, + // data: [ + // MessagingConstants.Event.Data.Key.UPDATE_PROPOSITIONS: true, + // MessagingConstants.Event.Data.Key.SURFACES: ["promos/feed1"] + // ]) + // mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: [:], status: SharedStateStatus.set)) + // mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: SampleEdgeIdentityState, status: SharedStateStatus.set)) + // + // // test + // XCTAssertNoThrow(messaging.handleProcessEvent(event)) + // XCTAssertEqual(1, mockRuntime.dispatchedEvents.count) + // let dispatchedEvent = mockRuntime.firstEvent + // XCTAssertEqual(EventType.edge, dispatchedEvent?.type) + // XCTAssertEqual(EventSource.requestContent, dispatchedEvent?.source) + // + // let eventData = try XCTUnwrap(dispatchedEvent?.data) + // let xdm = try XCTUnwrap(eventData["xdm"] as? [String: Any]) + // XCTAssertEqual("personalization.request", xdm["eventType"] as? String) + // let query = try XCTUnwrap(eventData["query"] as? [String: Any]) + // let personalization = try XCTUnwrap(query["personalization"] as? [String: Any]) + // let surfaces = try XCTUnwrap(personalization["surfaces"] as? [String]) + // XCTAssertEqual(1, surfaces.count) + // XCTAssertEqual("mobileapp://com.apple.dt.xctest.tool/promos/feed1", surfaces[0]) + // } func testHandleProcessEventNoIdentityMap() throws { // setup - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: [:], status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertEqual(0, mockRuntime.dispatchedEvents.count, "push token event should not be dispatched") } - + func testhandleProcessEventNoEcidArrayInIdentityMap() { - let mockConfig = [MessagingConstants.SharedState.Configuration.EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] + let mockConfig = [EXPERIENCE_CLOUD_ORG: MOCK_EXP_ORG_ID] let eventData: [String: Any] = [MessagingConstants.Event.Data.Key.PUSH_IDENTIFIER: MOCK_PUSH_TOKEN] let event = Event(name: "handleProcessEvent", type: EventType.genericIdentity, source: EventSource.requestContent, data: eventData) mockRuntime.simulateSharedState(for: MessagingConstants.SharedState.Configuration.NAME, data: (value: mockConfig, status: SharedStateStatus.set)) mockRuntime.simulateXDMSharedState(for: MessagingConstants.SharedState.EdgeIdentity.NAME, data: (value: [ - MessagingConstants.SharedState.EdgeIdentity.IDENTITY_MAP: [:] + MessagingConstants.SharedState.EdgeIdentity.IDENTITY_MAP: [:] as [String: Any] ], status: SharedStateStatus.set)) - + // test XCTAssertNoThrow(messaging.handleProcessEvent(event)) XCTAssertEqual(0, mockRuntime.dispatchedEvents.count, "push token event should not be dispatched") } + + // func testParsePropositionsHappy() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: true) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsDefaultSavesToPersitence() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsClearExisting() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: true) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsMismatchedScope() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("wrongScopeRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: true) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsEmptyStringContent() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("emptyContentStringRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: true) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsMalformedContent() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("malformedContentRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: true) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsNoItemsInPayload() throws { + // // setup + // let proposition = Proposition(itemId: "a", scope: "a", scopeDetails: [:], items: []) + // + // // test + // let rules = messaging.parsePropositions([proposition], expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsEmptyContentInPayload() throws { + // // setup + // let itemData = ItemData(content: "") + // let payloadItem = PayloadItem(data: itemData) + // let propositionItem = PropositionItem(itemId: "a", schema: "a", content: "a") + // let propInfo = PropositionInfo(id: "a", scope: "a", scopeDetails: [:]) + // let proposition = Proposition(itemId: "a", scope: "a", scopeDetails: [:], items: [propositionItem]) + // + // // test + // let rules = messaging.parsePropositions([proposition], expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsEventSequence() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("eventSequenceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + +// func testParsePropositionsEmptyPropositions() throws { +// // setup +// let propositions: [Proposition] = [] +// +// // test +// let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockFeedSurface], clearExisting: false) +// +// // verify +// XCTAssertEqual(0, rules.count) +// XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) +// XCTAssertFalse(mockCache.setCalled) +// } + + // func testParsePropositionsExistingReplacedWithEmpty() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // var rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertEqual(1, messaging.propositionInfoCount()) + // XCTAssertTrue(mockCache.setCalled) + // + // // test + // rules = messaging.parsePropositions(nil, expectedSurfaces: [mockIamSurface], clearExisting: true, persistChanges: true) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(0, messaging.inMemoryPropositionsCount()) + // XCTAssertEqual(0, messaging.propositionInfoCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsExistingNoReplacedWithEmpty() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // var rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertEqual(1, messaging.propositionInfoCount()) + // XCTAssertTrue(mockCache.setCalled) + // + // // test + // rules = messaging.parsePropositions(nil, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: true) + // + // // verify + // XCTAssertEqual(0, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertEqual(1, messaging.propositionInfoCount()) + // XCTAssertTrue(mockCache.setCalled) + // } + // + // func testParsePropositionsDoNotPersistChanges() throws { + // // setup + // let decoder = JSONDecoder() + // let propString: String = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let propositions = try decoder.decode([Proposition].self, from: propString.data(using: .utf8)!) + // + // // test + // let rules = messaging.parsePropositions(propositions, expectedSurfaces: [mockIamSurface], clearExisting: false, persistChanges: false) + // + // // verify + // XCTAssertEqual(1, rules.count) + // XCTAssertEqual(1, messaging.inMemoryPropositionsCount()) + // XCTAssertFalse(mockCache.setCalled) + // } + + func testPropositionInfoForMessageIdHappy() throws { + // setup + messaging.propositionInfo["id"] = PropositionInfo(id: "pid", scope: "scope", scopeDetails: [:]) + + // test + let propInfo = messaging.propositionInfoFor(messageId: "id") + + // verify + XCTAssertNotNil(propInfo) + XCTAssertEqual("pid", propInfo?.id) + XCTAssertEqual("scope", propInfo?.scope) + XCTAssertEqual(0, propInfo?.scopeDetails.count) + } + + func testPropositionInfoForMessageIdNoMatch() throws { + // test + let propInfo = messaging.propositionInfoFor(messageId: "good luck finding a message with this id. ha!") + + // verify + XCTAssertNil(propInfo) + } + + // func testLoadCachedPropositionsHappy() throws { + // // setup + // let aJsonString = JSONFileLoader.getRulesStringFromFile("showOnceRule") + // let cacheEntry = CacheEntry(data: aJsonString.data(using: .utf8)!, expiry: .never, metadata: nil) + // mockCache.getReturnValue = cacheEntry + // + // // test + // messaging.loadCachedPropositions() + // + // // verify + // XCTAssertTrue(mockCache.getCalled) + // XCTAssertEqual("propositions", mockCache.getParamKey) + // XCTAssertTrue(mockLaunchRulesEngine.addRulesCalled) + // XCTAssertEqual(1, mockLaunchRulesEngine.paramAddRulesRules?.count) + // } + + func testLoadCachedPropositionsWrongScope() throws { + // setup + let aJsonString = JSONFileLoader.getRulesStringFromFile("wrongScopeRule") + let cacheEntry = CacheEntry(data: aJsonString.data(using: .utf8)!, expiry: .never, metadata: nil) + mockCache.getReturnValue = cacheEntry + + // test + messaging.loadCachedPropositions() - // MARK: - Helpers + // verify + XCTAssertTrue(mockCache.getCalled) + XCTAssertEqual("propositions", mockCache.getParamKey) + XCTAssertFalse(mockLaunchRulesEngine.addRulesCalled) + } + + func testLoadCachedPropositionsNoCacheFound() throws { + // setup + mockCache.getReturnValue = nil + + // test + messaging.loadCachedPropositions() + // verify + XCTAssertTrue(mockCache.getCalled) + XCTAssertEqual("propositions", mockCache.getParamKey) + XCTAssertFalse(mockLaunchRulesEngine.addRulesCalled) + XCTAssertFalse(mockLaunchRulesEngine.replaceRulesCalled) + } + + // MARK: - Helpers + func readJSONFromFile(fileName: String) -> [String: Any]? { var json: Any? - + guard let pathString = Bundle(for: type(of: self)).path(forResource: fileName, ofType: "json") else { print("\(fileName).json not found") return [:] @@ -516,7 +1018,7 @@ class MessagingTests: XCTestCase { } return json as? [String: Any] } - + func convertToDictionary(text: String) -> [String: Any]? { if let data = text.data(using: .utf8) { do { @@ -527,49 +1029,76 @@ class MessagingTests: XCTestCase { } return nil } - - let mockContent1 = "content1" - let mockContent2 = "content2" + + let mockFeedContent = "{\"version\":1,\"rules\":[{\"condition\":{\"definition\":{\"conditions\":[{\"definition\":{\"key\":\"~timestampu\",\"matcher\":\"ge\",\"values\":[1680568056]},\"type\":\"matcher\"},{\"definition\":{\"key\":\"~timestampu\",\"matcher\":\"le\",\"values\":[1712190456]},\"type\":\"matcher\"}],\"logic\":\"and\"},\"type\":\"group\"},\"consequences\":[{\"id\":\"6513c398-303a-4a04-adbf-116b194bcaea\",\"type\":\"cjmiam\",\"detail\":{\"mobileParameters\":{\"expiryDate\":1712190456,\"actionTitle\":\"Shop the sale!\",\"meta\":{\"feedName\":\"Winter Promo\"},\"imageUrl\":\"https://luma.com/wintersale.png\",\"actionUrl\":\"https://luma.com/sale\",\"publishedDate\":1680568056,\"body\":\"All winter gear is now up to 30% off at checkout.\",\"title\":\"Flash sale!\",\"type\":\"messagefeed\"},\"html\":\"\",\"remoteAssets\":[]}}]}]}" + + let mockContent1 = "{\"version\":1,\"rules\":[{\"condition\":{\"type\":\"group\",\"definition\":{\"logic\":\"and\",\"conditions\":[{\"type\":\"group\",\"definition\":{\"logic\":\"and\",\"conditions\":[{\"type\":\"matcher\",\"definition\":{\"key\":\"~source\",\"matcher\":\"eq\",\"values\":[\"com.adobe.eventSource.applicationLaunch\"]}},{\"type\":\"matcher\",\"definition\":{\"key\":\"~type\",\"matcher\":\"eq\",\"values\":[\"com.adobe.eventType.lifecycle\"]}},{\"type\":\"matcher\",\"definition\":{\"key\":\"~state.com.adobe.module.lifecycle/lifecyclecontextdata.launchevent\",\"matcher\":\"ex\",\"values\":[]}}]}}]}},\"consequences\":[{\"id\":\"89ac1647-d48b-4206-a302-c74353e63fc7\",\"type\":\"schema\",\"detail\":{\"id\":\"89ac1647-d48b-4206-a302-ffffffffffff\",\"schema\":\"https://ns.adobe.com/personalization/message/in-app\",\"data\":{\"publishedDate\":1701538942,\"expiryDate\":1712190456,\"meta\":{\"metaKey\":\"metaValue\"},\"contentType\":\"content/json\",\"content\":{\"mobileParameters\":{\"verticalAlign\":\"center\",\"horizontalInset\":0,\"dismissAnimation\":\"bottom\",\"uiTakeover\":true,\"horizontalAlign\":\"center\",\"verticalInset\":0,\"displayAnimation\":\"bottom\",\"width\":100,\"height\":100,\"gestures\":{}},\"html\":\"Hello from another InApp campaign: [CIT]::inapp::LqhnZy7y1Vo4EEWciU5qK\",\"remoteAssets\":[]}}}}]}]}" + let mockContent2 = "{\"version\":1,\"rules\":[{\"condition\":{\"type\":\"group\",\"definition\":{\"logic\":\"and\",\"conditions\":[{\"type\":\"group\",\"definition\":{\"logic\":\"and\",\"conditions\":[{\"type\":\"matcher\",\"definition\":{\"key\":\"~source\",\"matcher\":\"eq\",\"values\":[\"com.adobe.eventSource.applicationLaunch\"]}},{\"type\":\"matcher\",\"definition\":{\"key\":\"~type\",\"matcher\":\"eq\",\"values\":[\"com.adobe.eventType.lifecycle\"]}},{\"type\":\"matcher\",\"definition\":{\"key\":\"~state.com.adobe.module.lifecycle/lifecyclecontextdata.launchevent\",\"matcher\":\"ex\",\"values\":[]}}]}}]}},\"consequences\":[{\"id\":\"dcfc8404-eea2-49df-a39a-85fc262d897e\",\"type\":\"schema\",\"detail\":{\"id\":\"dcfc8404-eea2-49df-a39a-ffffffffffff\",\"schema\":\"https://ns.adobe.com/personalization/message/in-app\",\"data\":{\"publishedDate\":1701538942,\"expiryDate\":1712190456,\"meta\":{\"metaKey\":\"metaValue\"},\"contentType\":\"content/json\",\"content\":\"{\\\"mobileParameters\\\":{\\\"verticalAlign\\\":\\\"center\\\",\\\"horizontalInset\\\":0,\\\"dismissAnimation\\\":\\\"bottom\\\",\\\"uiTakeover\\\":true,\\\"horizontalAlign\\\":\\\"center\\\",\\\"verticalInset\\\":0,\\\"displayAnimation\\\":\\\"bottom\\\",\\\"width\\\":100,\\\"height\\\":100,\\\"gestures\\\":{}},\\\"html\\\":\\\"Hello from another InApp campaign: [CIT]::inapp::LqhnZy7y1Vo4EEWciU5qK\\\",\\\"remoteAssets\\\":[]}\"}}}]}]}" + let mockPayloadId1 = "id1" let mockPayloadId2 = "id2" let mockAppSurface = "mobileapp://com.apple.dt.xctest.tool" - func getOfferEventData(items: [String: Any]? = nil, scope: String? = nil) -> [String: Any] { - let data1 = ["content": mockContent1] - let item1 = ["data": data1] - let payload1: [String: Any] = [ - "id": mockPayloadId1, - "scope": scope ?? mockAppSurface, - "scopeDetails": [ - "someInnerKey": "someInnerValue" - ], - "items": items ?? [item1] - ] - - let data2 = ["content": mockContent2] - let item2 = ["data": data2] - let payload2: [String: Any] = [ - "id": mockPayloadId2, - "scope": scope ?? mockAppSurface, - "scopeDetails": [ - "someInnerKey": "someInnerValue2" - ], - "items": items ?? [item2] - ] + func getOfferEventData(items: [[String: Any]]? = nil, surface: String? = nil, requestEventId: String = "mockRequestEventId") -> [String: Any] { - let eventData: [String: Any] = ["payload": [payload1, payload2]] + var eventData: [String: Any] = [:] + if let items = items, !items.isEmpty { + let payload: [String: Any] = [ + "id": mockPayloadId1, + "scope": surface ?? mockAppSurface, + "scopeDetails": [ + "someInnerKey": "someInnerValue" + ], + "items": items + ] + + eventData = ["payload": [payload], "requestEventId": requestEventId] + } else { + let data1 = ["content": mockContent1] + let item1 = [ + "id": "abc", + "schema": "https://ns.adobe.com/personalization/json-content-item", + "data": data1 + ] as [String: Any] + let payload1: [String: Any] = [ + "id": mockPayloadId1, + "scope": surface ?? mockAppSurface, + "scopeDetails": [ + "someInnerKey": "someInnerValue" + ], + "items": [item1] + ] + + let data2 = ["content": mockContent2] + let item2 = [ + "id": "abc", + "schema": "https://ns.adobe.com/personalization/json-content-item", + "data": data2 + ] as [String: Any] + let payload2: [String: Any] = [ + "id": mockPayloadId2, + "scope": surface ?? mockAppSurface, + "scopeDetails": [ + "someInnerKey": "someInnerValue2" + ], + "items": [item2] + ] + + eventData = ["payload": [payload1, payload2], "requestEventId": requestEventId] + } return eventData } - func getRulesResponseEventData(html: String? = "this is the html") -> [String: Any] { + func getRulesResponseEventData(html: String? = "this is the html", id: String = "mockMessageId") -> [String: Any] { var detailDictionary: [String: Any] = [:] if html != nil { detailDictionary["html"] = html } return [ MessagingConstants.Event.Data.Key.TRIGGERED_CONSEQUENCE: [ - MessagingConstants.Event.Data.Key.TYPE: MessagingConstants.ConsequenceTypes.IN_APP_MESSAGE, + MessagingConstants.Event.Data.Key.ID: id, + MessagingConstants.Event.Data.Key.TYPE: MessagingConstants.ConsequenceTypes.SCHEMA, MessagingConstants.Event.Data.Key.DETAIL: detailDictionary - ] + ] as [String: Any] ] } diff --git a/AEPMessaging/Tests/UnitTests/ParsedPropositionsTests.swift b/AEPMessaging/Tests/UnitTests/ParsedPropositionsTests.swift new file mode 100644 index 00000000..ebf08e80 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/ParsedPropositionsTests.swift @@ -0,0 +1,284 @@ +/* + Copyright 2023 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 Foundation +import XCTest +import AEPTestUtils +@testable import AEPMessaging +@testable import AEPServices + +class ParsedPropositionTests: XCTestCase { + var mockSurface: Surface! + var mockRuntime: TestableExtensionRuntime! + + let rulesetSchema: SchemaType = .ruleset + let jsonSchema: SchemaType = .jsonContent + let htmlSchema: SchemaType = .htmlContent + + var mockInAppPropositionItemv2: PropositionItem! + var mockInAppPropositionv2: Proposition! + var mockInAppSurfacev2: Surface! + let mockInAppMessageIdv2 = "6ac78390-84e3-4d35-b798-8e7080e69a67" + + var mockFeedPropositionItem: PropositionItem! + var mockFeedProposition: Proposition! + var mockFeedSurface: Surface! + let mockFeedMessageId = "183639c4-cb37-458e-a8ef-4e130d767ebf" + var mockFeedContent: [String: Any]! + + var mockCodeBasedPropositionItem: PropositionItem! + var mockCodeBasedProposition: Proposition! + var mockCodeBasedSurface: Surface! + var mockCodeBasedContent: [String: Any]! + + override func setUp() { + mockSurface = Surface(uri: "mobileapp://some.not.matching.surface/path") + mockRuntime = TestableExtensionRuntime() + + let inappPropositionV2Content = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + mockInAppPropositionItemv2 = PropositionItem(itemId: "inapp2", schema: rulesetSchema, itemData: inappPropositionV2Content) + mockInAppPropositionv2 = Proposition(uniqueId: "inapp2", scope: "inapp2", scopeDetails: ["key": "value"], items: [mockInAppPropositionItemv2]) + mockInAppSurfacev2 = Surface(uri: "inapp2") + + mockFeedContent = JSONFileLoader.getRulesJsonFromFile("feedPropositionContent") + mockFeedPropositionItem = PropositionItem(itemId: "feed", schema: rulesetSchema, itemData: mockFeedContent) + mockFeedProposition = Proposition(uniqueId: "feed", scope: "feed", scopeDetails: ["key":"value"], items: [mockFeedPropositionItem]) + mockFeedSurface = Surface(uri: "feed") + + mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + mockCodeBasedPropositionItem = PropositionItem(itemId: "codebased", schema: htmlSchema, itemData: mockCodeBasedContent) + mockCodeBasedProposition = Proposition(uniqueId: "codebased", scope: "codebased", scopeDetails: ["key":"value"], items: [mockCodeBasedPropositionItem]) + mockCodeBasedSurface = Surface(uri: "codebased") + } + + func testInitWithEmptyPropositions() throws { + // setup + let propositions: [Surface: [Proposition]] = [mockSurface: []] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockSurface], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitWithPropositionScopeNotMatchingRequestedSurfaces() throws { + // setup + let propositions: [Surface: [Proposition]] = [ + mockFeedSurface: [mockFeedProposition], + mockCodeBasedSurface: [mockCodeBasedProposition] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockSurface], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitWithInAppPropositionV2() throws { + // setup + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [mockInAppPropositionv2] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(1, result.propositionInfoToCache.count, "should have one IAM in propositionInfo for tracking purposes") + let iamPropInfo = result.propositionInfoToCache[mockInAppMessageIdv2] + XCTAssertEqual("inapp2", iamPropInfo?.id) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(1, result.propositionsToPersist.count, "should have one entry for persistence") + let iamPersist = result.propositionsToPersist[mockInAppSurfacev2] + XCTAssertEqual(1, iamPersist?.count) + XCTAssertEqual("inapp2", iamPersist?.first?.uniqueId) + XCTAssertEqual(1, result.surfaceRulesBySchemaType.count, "should have one rule to insert in the IAM rules engine") + let iamRules = result.surfaceRulesBySchemaType[.inapp] + XCTAssertEqual(1, iamRules?.count) + let firstConsequence = iamRules?.first?.value.first?.consequences.first + XCTAssertNotNil(firstConsequence) + let consequenceAsPropositionItem = PropositionItem.fromRuleConsequence(firstConsequence!) + let inappSchemaData = consequenceAsPropositionItem?.inappSchemaData + XCTAssertEqual("text/html", inappSchemaData?.contentType.toString()) + XCTAssertEqual("Is this thing even on?", inappSchemaData?.content as? String) + XCTAssertEqual(1691541497, inappSchemaData?.publishedDate) + XCTAssertEqual(1723163897, inappSchemaData?.expiryDate) + XCTAssertEqual(1, inappSchemaData?.meta?.count) + XCTAssertEqual("metaValue", inappSchemaData?.meta?["metaKey"] as? String) + XCTAssertEqual(13, inappSchemaData?.mobileParameters?.count) + XCTAssertEqual(1, inappSchemaData?.webParameters?.count) + XCTAssertEqual("webParamValue", inappSchemaData?.webParameters?["webParamKey"] as? String) + XCTAssertEqual(1, inappSchemaData?.remoteAssets?.count) + XCTAssertEqual("urlToAnImage", inappSchemaData?.remoteAssets?.first) + } + + func testInitWithFeedProposition() throws { + // setup + let propositions: [Surface: [Proposition]] = [ + mockFeedSurface: [mockFeedProposition] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockFeedSurface], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(1, result.propositionInfoToCache.count, "should have one entry in proposition info for tracking purposes") + let feedPropositionInfo = result.propositionInfoToCache[mockFeedMessageId] + XCTAssertNotNil(feedPropositionInfo) + XCTAssertEqual("feed", feedPropositionInfo?.id) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(1, result.surfaceRulesBySchemaType.count, "should have one rule to insert in the feeds rules engine") + let feedRules = result.surfaceRulesBySchemaType[.feed] + XCTAssertNotNil(feedRules) + XCTAssertEqual(1, feedRules?.count) + } + + func testInitWithCodeBasedProposition() throws { + // setup + let propositions: [Surface: [Proposition]] = [ + mockCodeBasedSurface: [mockCodeBasedProposition] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockCodeBasedSurface], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(1, result.propositionsToCache.count, "code based proposition should be cached") + let codeBasedPropItem = result.propositionsToCache[mockCodeBasedSurface]?.first?.items.first + XCTAssertEqual(mockCodeBasedContent["content"] as? String, codeBasedPropItem?.htmlContent) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitWithDefaultContentProposition() throws { + + } + + func testInitPropositionItemEmptyContentString() throws { + // setup + mockInAppPropositionItemv2 = PropositionItem(itemId: "inapp", schema: .inapp, itemData: [:]) + mockInAppPropositionv2 = Proposition(uniqueId: "inapp", scope: "inapp", scopeDetails: ["key": "value"], items: [mockInAppPropositionItemv2]) + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [mockInAppPropositionv2] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitPropositionRuleHasNoConsequence() throws { + // setup + let noConsequenceRule = JSONFileLoader.getRulesJsonFromFile("ruleWithNoConsequence") + let pi = PropositionItem(itemId: "inapp", schema: .ruleset, itemData: noConsequenceRule) + let prop = Proposition(uniqueId: "inapp", scope: "inapp", scopeDetails: ["key": "value"], items: [pi]) + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [prop] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitPropositionRulesetConsequenceHasUnknownSchema() throws { + // setup + let content = JSONFileLoader.getRulesJsonFromFile("ruleWithUnknownConsequenceSchema") + let pi = PropositionItem(itemId: "inapp", schema: .ruleset, itemData: content) + let prop = Proposition(uniqueId: "inapp", scope: "inapp", scopeDetails: ["key": "value"], items: [pi]) + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [prop] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitPropositionUnknownSchema() throws { + // setup + let pi = PropositionItem(itemId: "inapp", schema: .unknown, itemData: [:]) + let prop = Proposition(uniqueId: "inapp", scope: "inapp", scopeDetails: ["key": "value"], items: [pi]) + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [prop] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitPropositionConsequenceNoPropositionItem() throws { + // setup + let prop = Proposition(uniqueId: "inapp", scope: "inapp", scopeDetails: ["key": "value"], items: []) + let propositions: [Surface: [Proposition]] = [ + mockInAppSurfacev2: [prop] + ] + + // test + let result = ParsedPropositions(with: propositions, requestedSurfaces: [mockInAppSurfacev2], runtime: mockRuntime) + + // verify + XCTAssertNotNil(result) + XCTAssertEqual(0, result.propositionInfoToCache.count) + XCTAssertEqual(0, result.propositionsToCache.count) + XCTAssertEqual(0, result.propositionsToPersist.count) + XCTAssertEqual(0, result.surfaceRulesBySchemaType.count) + } + + func testInitPropositionRulesetDoesNotParseToRules() throws { + + } + + func testInitPropositionRulesetConsequenceIsNotSchemaType() throws { + + } +} diff --git a/AEPMessaging/Tests/UnitTests/PayloadItemTests.swift b/AEPMessaging/Tests/UnitTests/PayloadItemTests.swift deleted file mode 100644 index 7f33f79c..00000000 --- a/AEPMessaging/Tests/UnitTests/PayloadItemTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -/* - Copyright 2022 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 Foundation -import XCTest - -@testable import AEPMessaging - -class PayloadItemTests: XCTestCase { - let mockDataId = "mockDataId" - let mockDataContent = "mockDataContent" - var mockItemData: ItemData! - - override func setUp() { - mockItemData = ItemData(id: mockDataId, content: mockDataContent) - } - - // MARK: - Happy path - func testIsConstructable() throws { - // setup - let payloadItem = PayloadItem(id: "id", schema: "schema", data: mockItemData) - - // verify - XCTAssertNotNil(payloadItem) - XCTAssertEqual("id", payloadItem.id) - XCTAssertEqual("schema", payloadItem.schema) - XCTAssertEqual(mockDataId, payloadItem.data.id) - XCTAssertEqual(mockDataContent, payloadItem.data.content) - } - - func testIsEncodable() throws { - // setup - let encoder = JSONEncoder() - let payloadItem = PayloadItem(id: "id", schema: "schema", data: mockItemData) - - // test - guard let encodedPayloadItem = try? encoder.encode(payloadItem) else { - XCTFail("unable to encode PayloadItem") - return - } - - // verify - XCTAssertEqual("{\"id\":\"id\",\"schema\":\"schema\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}", String(data: encodedPayloadItem, encoding: .utf8)) - } - - func testIsDecodable() throws { - // setup - let decoder = JSONDecoder() - let payloadItem = "{\"id\":\"id\",\"schema\":\"schema\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}".data(using: .utf8)! - - // test - guard let decodedPayloadItem = try? decoder.decode(PayloadItem.self, from: payloadItem) else { - XCTFail("unable to decode PayloadItem json") - return - } - - // verify - XCTAssertEqual("id", decodedPayloadItem.id) - XCTAssertEqual("schema", decodedPayloadItem.schema) - XCTAssertEqual(mockDataId, decodedPayloadItem.data.id) - XCTAssertEqual(mockDataContent, decodedPayloadItem.data.content) - } - - // MARK: - Exception path - func testIdIsOptional() throws { - // setup - let decoder = JSONDecoder() - let payloadItem = "{\"schema\":\"schema\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}".data(using: .utf8)! - - // test - guard let decodedPayloadItem = try? decoder.decode(PayloadItem.self, from: payloadItem) else { - XCTFail("unable to decode PayloadItem json") - return - } - - // verify - XCTAssertNil(decodedPayloadItem.id) - XCTAssertEqual("schema", decodedPayloadItem.schema) - XCTAssertEqual(mockDataId, decodedPayloadItem.data.id) - XCTAssertEqual(mockDataContent, decodedPayloadItem.data.content) - } - - func testSchemaIsOptional() throws { - // setup - let decoder = JSONDecoder() - let payloadItem = "{\"id\":\"id\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}".data(using: .utf8)! - - // test - guard let decodedPayloadItem = try? decoder.decode(PayloadItem.self, from: payloadItem) else { - XCTFail("unable to decode PayloadItem json") - return - } - - // verify - XCTAssertEqual("id", decodedPayloadItem.id) - XCTAssertNil(decodedPayloadItem.schema) - XCTAssertEqual(mockDataId, decodedPayloadItem.data.id) - XCTAssertEqual(mockDataContent, decodedPayloadItem.data.content) - } - - func testDataIsRequired() throws { - // setup - let decoder = JSONDecoder() - let payloadItem = "{\"id\":\"id\",\"schema\":\"schema\"}".data(using: .utf8)! - - // test - let decodedPayloadItem = try? decoder.decode(PayloadItem.self, from: payloadItem) - - // verify - XCTAssertNil(decodedPayloadItem) - } -} diff --git a/AEPMessaging/Tests/UnitTests/PropositionInfoTests.swift b/AEPMessaging/Tests/UnitTests/PropositionInfoTests.swift index 1861b579..fcc7b808 100644 --- a/AEPMessaging/Tests/UnitTests/PropositionInfoTests.swift +++ b/AEPMessaging/Tests/UnitTests/PropositionInfoTests.swift @@ -15,8 +15,9 @@ import XCTest @testable import AEPMessaging import AEPServices +import AEPTestUtils -class PropositionInfoTests: XCTestCase { +class PropositionInfoTests: XCTestCase, AnyCodableAsserts { let mockId = "mockId" let mockScope = "mockScope" let mockCorrelationId = "mockCorrelationId" @@ -51,6 +52,7 @@ class PropositionInfoTests: XCTestCase { // setup let encoder = JSONEncoder() let propositionInfo = PropositionInfo(id: mockId, scope: mockScope, scopeDetails: mockScopeDetails) + let expected = "{\"id\":\"\(mockId)\",\"scope\":\"\(mockScope)\",\"scopeDetails\":{\"activity\":{\"id\":\"\(mockActivityId)\"},\"correlationID\":\"\(mockCorrelationId)\"}}".toAnyCodable() ?? "fail" // test guard let encodedPropositionInfo = try? encoder.encode(propositionInfo) else { @@ -59,7 +61,8 @@ class PropositionInfoTests: XCTestCase { } // verify - XCTAssertEqual("{\"id\":\"\(mockId)\",\"scope\":\"\(mockScope)\",\"scopeDetails\":{\"activity\":{\"id\":\"\(mockActivityId)\"},\"correlationID\":\"\(mockCorrelationId)\"}}", String(data: encodedPropositionInfo, encoding: .utf8)) + let actual = String(data: encodedPropositionInfo, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) } func testIsDecodable() throws { @@ -151,4 +154,43 @@ class PropositionInfoTests: XCTestCase { // verify XCTAssertEqual("", propositionInfo.activityId) } + + // MARK: - extension vars + func testActivityId() throws { + // setup + let propositionInfo = PropositionInfo(id: mockId, scope: mockScope, scopeDetails: mockScopeDetails) + + // verify + XCTAssertEqual(mockActivityId, propositionInfo.activityId) + } + + func testActivityIdNoActivityObject() throws { + // setup + let propositionInfo = PropositionInfo(id: mockId, scope: mockScope, scopeDetails: [ "noActivityObject": "foundHere" ]) + + // verify + XCTAssertEqual("", propositionInfo.activityId) + } + + func testActivityIdNoIdInActivityObject() throws { + // setup + let propositionInfo = PropositionInfo(id: mockId, scope: mockScope, scopeDetails: [ "activity": [ "noId": "foundHere" ]]) + + // verify + XCTAssertEqual("", propositionInfo.activityId) + } + + func testFromProposition() throws { + // setup + let propItem = PropositionItem(itemId: "itemId", schema: .defaultContent, itemData: [:]) + let proposition = Proposition(uniqueId: mockId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propItem]) + + // test + let propositionInfo = PropositionInfo.fromProposition(proposition) + + // verify + XCTAssertEqual(mockId, propositionInfo.id) + XCTAssertEqual(mockScope, propositionInfo.scope) + XCTAssertEqual(2, propositionInfo.scopeDetails.count) + } } diff --git a/AEPMessaging/Tests/UnitTests/PropositionInteractionTests.swift b/AEPMessaging/Tests/UnitTests/PropositionInteractionTests.swift new file mode 100644 index 00000000..bd83b78b --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/PropositionInteractionTests.swift @@ -0,0 +1,216 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class PropositionInteractionTests: XCTestCase, AnyCodableAsserts { + + let mockDisplayEventType: MessagingEdgeEventType = .display + let mockInteractEventType: MessagingEdgeEventType = .interact + let mockItemId = "mockItemId" + let mockTokens = ["token1"] + var mockPropositionInfo: PropositionInfo! + + override func setUp() { + mockPropositionInfo = PropositionInfo(id: "mockPropositionId", scope: "mockScope", scopeDetails: AnyCodable.from(dictionary: ["key": "value"]) ?? [:]) + } + + func getDecodedObject(fromString: String) -> PropositionInteraction? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let propositionInteraction = try? decoder.decode(PropositionInteraction.self, from: objectData) else { + return nil + } + return propositionInteraction + } + + func testPropositionInteractionInit() { + // test + let propositionInteraction = PropositionInteraction(eventType: mockDisplayEventType, interaction: "", propositionInfo: mockPropositionInfo, itemId: mockItemId, tokens: nil) + + // verify + XCTAssertNotNil(propositionInteraction) + XCTAssertEqual(mockDisplayEventType, propositionInteraction.eventType) + XCTAssertEqual("", propositionInteraction.interaction) + XCTAssertEqual(mockPropositionInfo.id, propositionInteraction.propositionInfo.id) + XCTAssertEqual(mockPropositionInfo.scope, propositionInteraction.propositionInfo.scope) + XCTAssertEqual(AnyCodable(mockPropositionInfo.scopeDetails), AnyCodable(propositionInteraction.propositionInfo.scopeDetails)) + XCTAssertEqual(mockItemId, propositionInteraction.itemId) + } + + func testPropositionInteractionIsDecodable() { + // setup + let propositionInteractionJsonString = #""" + { + "eventType": "decisioning.propositionDisplay", + "propositionInfo": { + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": { + "key": "value" + } + }, + "interaction": "", + "itemId": "mockItemId", + "tokens": ["token1", "token2"] + } + """# + + // test + guard let propositionInteraction = getDecodedObject(fromString: propositionInteractionJsonString) else { + XCTFail("Proposition Interaction object should be decodable.") + return + } + + // verify + XCTAssertNotNil(propositionInteraction) + XCTAssertEqual(mockDisplayEventType, propositionInteraction.eventType) + XCTAssertEqual("", propositionInteraction.interaction) + XCTAssertEqual(mockPropositionInfo.id, propositionInteraction.propositionInfo.id) + XCTAssertEqual(mockPropositionInfo.scope, propositionInteraction.propositionInfo.scope) + XCTAssertEqual(AnyCodable(mockPropositionInfo.scopeDetails), AnyCodable(propositionInteraction.propositionInfo.scopeDetails)) + XCTAssertEqual(mockItemId, propositionInteraction.itemId) + XCTAssertNotNil(propositionInteraction.tokens) + XCTAssertEqual(2, propositionInteraction.tokens?.count) + XCTAssertEqual(["token1", "token2"], propositionInteraction.tokens?.sorted()) + } + + func testPropositionInteractionIsEncodable() { + // setup + let propositionInteractionJsonString = #""" + { + "eventType": "decisioning.propositionInteract", + "propositionInfo": { + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": { + "key": "value" + } + }, + "interaction": "mockInteraction", + "itemId": "mockItemId", + "tokens": ["token1"] + } + """# + + guard let propositionInteraction = getDecodedObject(fromString: propositionInteractionJsonString) else { + XCTFail("Proposition Interaction object should be decodable.") + return + } + + let encoder = JSONEncoder() + let expected = propositionInteractionJsonString.toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(propositionInteraction) else { + XCTFail("Proposition Interaction object should be encodable.") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testPropositionInteractionDecodeInvalidEventType() { + // setup + let propositionInteractionJsonString = #""" + { + "eventType": "decisioning.propositionSomething", + "propositionInfo": { + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": { + "key": "value" + } + }, + "interaction": "", + "itemId": "mockItemId" + } + """# + + // test + let propositionInteraction = getDecodedObject(fromString: propositionInteractionJsonString) + + // verify + XCTAssertNil(propositionInteraction) + } + + func testPropositionInteractionXdmForInteract() throws { + // setup + let mockInteraction = "mockInteraction" + let propositionInteraction = PropositionInteraction(eventType: mockInteractEventType, interaction: mockInteraction, propositionInfo: mockPropositionInfo, itemId: mockItemId, tokens: mockTokens) + + // test + let xdm = propositionInteraction.xdm + + // verify + XCTAssertTrue(!xdm.isEmpty) + XCTAssertEqual(mockInteractEventType.toString(), xdm["eventType"] as? String) + let experience = try XCTUnwrap(xdm["_experience"] as? [String: Any]) + let decisioning = try XCTUnwrap(experience["decisioning"] as? [String: Any]) + let propositionEventType = try XCTUnwrap(decisioning["propositionEventType"] as? [String: Any]) + XCTAssertEqual(1, propositionEventType["interact"] as? Int) + + let propositions = try XCTUnwrap(decisioning["propositions"] as? [[String: Any]]) + XCTAssertEqual(1, propositions.count) + XCTAssertEqual(mockPropositionInfo.id, propositions[0]["id"] as? String) + XCTAssertEqual(mockPropositionInfo.scope, propositions[0]["scope"] as? String) + assertExactMatch(expected: AnyCodable(mockPropositionInfo.scopeDetails), actual: AnyCodable(propositions[0]["scopeDetails"])) + + let items = try XCTUnwrap(propositions[0]["items"] as? [[String: Any]]) + XCTAssertEqual(1, items.count) + XCTAssertEqual(mockItemId, items[0]["id"] as? String) + let itemCharacteristics = try XCTUnwrap(items[0]["characteristics"] as? [String: String]) + XCTAssertEqual(1, itemCharacteristics.count) + XCTAssertEqual(mockTokens.joined(separator: ","), itemCharacteristics["tokens"]) + + let propositionAction = try XCTUnwrap(decisioning["propositionAction"] as? [String: Any]) + XCTAssertEqual(2, propositionAction.count) + XCTAssertEqual(mockInteraction, propositionAction["id"] as? String) + XCTAssertEqual(mockInteraction, propositionAction["label"] as? String) + } + + func testPropositionInteractionXdmForDisplay() throws { + // setup + let mockInteraction = "" + let propositionInteraction = PropositionInteraction(eventType: mockDisplayEventType, interaction: mockInteraction, propositionInfo: mockPropositionInfo, itemId: mockItemId, tokens: nil) + + // test + let xdm = propositionInteraction.xdm + + // verify + XCTAssertTrue(!xdm.isEmpty) + XCTAssertEqual(mockDisplayEventType.toString(), xdm["eventType"] as? String) + let experience = try XCTUnwrap(xdm["_experience"] as? [String: Any]) + let decisioning = try XCTUnwrap(experience["decisioning"] as? [String: Any]) + let propositionEventType = try XCTUnwrap(decisioning["propositionEventType"] as? [String: Any]) + XCTAssertEqual(1, propositionEventType["display"] as? Int) + + let propositions = try XCTUnwrap(decisioning["propositions"] as? [[String: Any]]) + XCTAssertEqual(1, propositions.count) + XCTAssertEqual(mockPropositionInfo.id, propositions[0]["id"] as? String) + XCTAssertEqual(mockPropositionInfo.scope, propositions[0]["scope"] as? String) + assertExactMatch(expected: AnyCodable(mockPropositionInfo.scopeDetails), actual: AnyCodable(propositions[0]["scopeDetails"])) + + let items = try XCTUnwrap(propositions[0]["items"] as? [[String: Any]]) + XCTAssertEqual(1, items.count) + XCTAssertEqual(mockItemId, items[0]["id"] as? String) + + XCTAssertNil(decisioning["propositionAction"]) + } +} diff --git a/AEPMessaging/Tests/UnitTests/PropositionItemTests.swift b/AEPMessaging/Tests/UnitTests/PropositionItemTests.swift new file mode 100644 index 00000000..215d3329 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/PropositionItemTests.swift @@ -0,0 +1,627 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +@testable import AEPCore +import AEPServices +import AEPTestUtils + +class PropositionItemTests: XCTestCase, AnyCodableAsserts { + let ASYNC_TIMEOUT = 5.0 + let mockPropositionId = "mockPropositionId" + let mockScope = "mockScope" + let mockScopeDetails = ["key": "value"] + + let mockItemId = "mockItemId" + let mockHtmlSchema: SchemaType = .htmlContent + let mockJsonSchema: SchemaType = .jsonContent + let mockDefaultContentSchema: SchemaType = .defaultContent + let mockContent = "customContent" + let mockFormat: ContentType = .textHtml + + override func setUp() { + EventHub.shared.start() + registerMockExtension(MockExtension.self) + } + + override func tearDown() { + MockExtension.reset() + EventHub.reset() + } + + func getDecodedObject(fromString: String) -> PropositionItem? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let propositionItem = try? decoder.decode(PropositionItem.self, from: objectData) else { + return nil + } + return propositionItem + } + + func parseRuleConsequences(_ rule: [String: Any]) -> [RuleConsequence]? { + guard + let ruleData = try? JSONSerialization.data(withJSONObject: rule, options: .prettyPrinted), + let parsedRules = JSONRulesParser.parse(ruleData) else { + return nil + } + return parsedRules.first?.consequences + } + + func testPropositionItemInitHtml() { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + + // test + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + + // verify + XCTAssertEqual(mockItemId, propositionItem.itemId) + XCTAssertEqual(mockHtmlSchema, propositionItem.schema) + XCTAssertEqual(mockCodeBasedContent["content"] as? String, propositionItem.htmlContent) + } + + func testPropositionItemInitJson() { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionJsonContent") + + // test + let propositionItem = PropositionItem(itemId: mockItemId, schema: .jsonContent, itemData: mockCodeBasedContent) + + // verify + XCTAssertEqual(mockItemId, propositionItem.itemId) + XCTAssertEqual(mockJsonSchema, propositionItem.schema) + assertExactMatch(expected: AnyCodable(mockCodeBasedContent["content"] as? [String: Any]), actual: AnyCodable(propositionItem.jsonContentDictionary)) + } + + func testPropositionItemIsDecodable() { + // setup + let json = "{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockHtmlSchema.toString())\",\"data\":{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}}" + + // test + guard let propositionItem = getDecodedObject(fromString: json) else { + XCTFail("PropositionItem object should be decodable.") + return + } + + // verify + XCTAssertEqual(mockItemId, propositionItem.itemId) + XCTAssertEqual(mockHtmlSchema, propositionItem.schema) + XCTAssertEqual(mockContent, propositionItem.htmlContent) + } + + func testPropositionItemDecodeEmptyData() { + // setup + let json = "{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockDefaultContentSchema.toString())\",\"data\":{}}" + + // test + guard let propositionItem = getDecodedObject(fromString: json) else { + XCTFail("PropositionItem object should be decodable.") + return + } + + // verify + XCTAssertEqual(mockItemId, propositionItem.itemId) + XCTAssertEqual(mockDefaultContentSchema, propositionItem.schema) + XCTAssertTrue(propositionItem.itemData.isEmpty) + } + + func testPropositionItemIsEncodable() { + // setup + let json = "{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockHtmlSchema.toString())\",\"data\":{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}}" + + guard let propositionItem = getDecodedObject(fromString: json) else { + XCTFail("PropositionItem object should be decodable.") + return + } + + let encoder = JSONEncoder() + let expected = json.toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(propositionItem) else { + XCTFail("PropositionItem object should be encodable.") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testPropositionItemIdIsRequired() { + // setup + let json = "{\"schema\":\"\(mockHtmlSchema.toString())\",\"data\":{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}}" + + // test + let propositionItem = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(propositionItem) + } + + func testPropositionItemSchemaIsRequired() { + // setup + let json = "{\"id\":\"\(mockItemId)\",\"data\":{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}}" + + // test + let propositionItem = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(propositionItem) + } + + func testPropositionItemDataIsRequired() { + // setup + let json = "{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockHtmlSchema.toString())\"}" + + // test + let propositionItem = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(propositionItem) + } + + func testPropositionItemFromRuleConsequence() { + // setup + let mockFeedContent = JSONFileLoader.getRulesJsonFromFile("feedPropositionContent") + guard let feedConsequence = parseRuleConsequences(mockFeedContent)?.first else { + XCTFail("Feed consequence should be valid.") + return + } + + // test + let propositionItem = PropositionItem.fromRuleConsequence(feedConsequence) + + let expectedData = #""" + { + "expiryDate": 1723163897, + "meta": { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + }, + "content": { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + }, + "contentType": "application/json", + "publishedDate": 1691541497 + } + """# + + // verify + XCTAssertNotNil(propositionItem) + XCTAssertEqual("183639c4-cb37-458e-a8ef-4e130d767ebf", propositionItem?.itemId) + XCTAssertEqual(.feed, propositionItem?.schema) + assertExactMatch(expected: expectedData.toAnyCodable()!, actual: propositionItem?.itemData.toAnyCodable(), pathOptions: []) + } + + func testPropositionItemFromRuleConsequenceEvent() { + let testEventData: [String: Any] = [ + "triggeredconsequence": [ + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "type": "schema", + "detail": [ + "id": "183639c4-cb37-458e-a8ef-4e130d767ebf", + "schema": "https://ns.adobe.com/personalization/message/feed-item", + "data": [ + "expiryDate": 1723163897, + "meta": [ + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + ], + "content": [ + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + ], + "contentType": "application/json", + "publishedDate": 1691541497 + ] + ] + ] + ] + + let testEvent = Event(name: "Rules Consequence Event", + type: "com.adobe.eventType.rulesEngine", + source: "com.adobe.eventSource.responseContent", + data: testEventData) + + // test + let propositionItem = PropositionItem.fromRuleConsequenceEvent(testEvent) + + let expectedData = #""" + { + "expiryDate": 1723163897, + "meta": { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + }, + "content": { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + }, + "contentType": "application/json", + "publishedDate": 1691541497 + } + """# + + // verify + XCTAssertNotNil(propositionItem) + XCTAssertEqual("183639c4-cb37-458e-a8ef-4e130d767ebf", propositionItem?.itemId) + XCTAssertEqual(.feed, propositionItem?.schema) + assertExactMatch(expected: expectedData.toAnyCodable()!, actual: propositionItem?.itemData.toAnyCodable(), pathOptions: []) + + } + + func testPropositionItemHasInAppSchemaData() { + // setup + let mockInappContent = JSONFileLoader.getRulesJsonFromFile("inappPropositionV2Content") + guard let inappConsequence = parseRuleConsequences(mockInappContent)?.first else { + XCTFail("Inapp consequence should be valid.") + return + } + + // test + let propositionItem = PropositionItem.fromRuleConsequence(inappConsequence) + let inappSchemaData = propositionItem?.inappSchemaData + + let expectedMobileParameters = #""" + { + "verticalAlign": "center", + "dismissAnimation": "bottom", + "verticalInset": 0, + "backdropOpacity": 0.2, + "cornerRadius": 15, + "gestures": {}, + "horizontalInset": 0, + "uiTakeover": true, + "horizontalAlign": "center", + "width": 100, + "displayAnimation": "bottom", + "backdropColor": "#000000", + "height": 100 + } + """# + + // verify + XCTAssertNotNil(inappSchemaData) + XCTAssertEqual(1691541497, inappSchemaData?.publishedDate) + XCTAssertEqual(1723163897, inappSchemaData?.expiryDate) + XCTAssertEqual(1, inappSchemaData?.meta?.count) + XCTAssertEqual("metaValue", inappSchemaData?.meta?["metaKey"] as? String) + XCTAssertEqual("urlToAnImage", inappSchemaData?.remoteAssets?.first) + XCTAssertEqual(.textHtml, inappSchemaData?.contentType) + XCTAssertEqual("Is this thing even on?", inappSchemaData?.content as? String) + assertExactMatch(expected: expectedMobileParameters.toAnyCodable()!, actual: inappSchemaData?.mobileParameters?.toAnyCodable(), pathOptions: []) + XCTAssertEqual("webParamValue", inappSchemaData?.webParameters?["webParamKey"] as? String) + } + + func testPropositionItemHasFeedItemSchemaData() { + // setup + let mockFeedContent = JSONFileLoader.getRulesJsonFromFile("feedPropositionContent") + guard let feedConsequence = parseRuleConsequences(mockFeedContent)?.first else { + XCTFail("") + return + } + + let propositionItem = PropositionItem.fromRuleConsequence(feedConsequence) + + // test + let feedItemSchemaData = propositionItem?.feedItemSchemaData + + let expectedContent = #""" + { + "title": "Guacamole!", + "body": "I'm the queen of Nacho Picchu and I'm really glad to meet you. To spice up this big tortilla chip, I command you to find a big dip.", + "imageUrl": "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:d4b77a01-610a-4c3f-9be6-5ebe1bd13da3/oak:1.0::ci:fa54b394b6f987d974d8619833083519/8933c829-3ab2-38e8-a1ee-00d4f562fff8", + "actionUrl": "https://luma.com/guacamolethemusical", + "actionTitle": "guacamole!" + } + """# + + let expectedMeta = #""" + { + "feedName": "testFeed", + "campaignName": "testCampaign", + "surface": "mobileapp://com.feeds.testing/feeds/apifeed" + } + """# + + // verify + XCTAssertNotNil(feedItemSchemaData) + XCTAssertEqual(1723163897, feedItemSchemaData?.expiryDate) + XCTAssertEqual(1691541497, feedItemSchemaData?.publishedDate) + XCTAssertEqual(ContentType.applicationJson, feedItemSchemaData?.contentType) + let feedItemContent = feedItemSchemaData?.content as? [String: Any] + assertExactMatch(expected: expectedContent.toAnyCodable()!, actual: feedItemContent?.toAnyCodable(), pathOptions: []) + assertExactMatch(expected: expectedMeta.toAnyCodable()!, actual: feedItemSchemaData?.meta?.toAnyCodable(), pathOptions: []) + } + + func testPropositionItemGenerateInteractionXdm() throws { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + let proposition = Proposition(uniqueId: mockPropositionId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propositionItem]) + + // test + guard let xdm = proposition.items[0].generateInteractionXdm("buttonTap", withEdgeEventType: MessagingEdgeEventType.interact) else { + XCTFail("Interaction XDM should not be nil") + return + } + + // verify + XCTAssertEqual("decisioning.propositionInteract", xdm["eventType"] as? String) + let experience = try XCTUnwrap(xdm["_experience"] as? [String: Any]) + let decisioning = try XCTUnwrap(experience["decisioning"] as? [String: Any]) + let propositionEventType = try XCTUnwrap(decisioning["propositionEventType"] as? [String: Any]) + XCTAssertEqual(1, propositionEventType["interact"] as? Int) + + let propositionAction = try XCTUnwrap(decisioning["propositionAction"] as? [String: Any]) + XCTAssertEqual("buttonTap", propositionAction["id"] as? String) + XCTAssertEqual("buttonTap", propositionAction["label"] as? String) + + let propositions = try XCTUnwrap(decisioning["propositions"] as? [[String: Any]]) + XCTAssertEqual(1, propositions.count) + XCTAssertEqual(proposition.uniqueId, propositions[0]["id"] as? String) + XCTAssertEqual(proposition.scope, propositions[0]["scope"] as? String) + assertExactMatch(expected: AnyCodable(proposition.scopeDetails), actual: AnyCodable(propositions[0]["scopeDetails"])) + + let items = try XCTUnwrap(propositions[0]["items"] as? [[String: Any]]) + XCTAssertEqual(1, items.count) + XCTAssertEqual(mockItemId, items[0]["id"] as? String) + } + + func testPropositionItemGenerateInteractionXdmForTokens() throws { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + let proposition = Proposition(uniqueId: mockPropositionId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propositionItem]) + + // test + guard let xdm = proposition.items[0].generateInteractionXdm(withEdgeEventType: MessagingEdgeEventType.display, forTokens: ["token1", "token2"]) else { + XCTFail("Interaction XDM should not be nil") + return + } + + // verify + XCTAssertEqual("decisioning.propositionDisplay", xdm["eventType"] as? String) + let experience = try XCTUnwrap(xdm["_experience"] as? [String: Any]) + let decisioning = try XCTUnwrap(experience["decisioning"] as? [String: Any]) + let propositionEventType = try XCTUnwrap(decisioning["propositionEventType"] as? [String: Any]) + XCTAssertEqual(1, propositionEventType["display"] as? Int) + XCTAssertNil(decisioning["propositionAction"] as? [String: Any]) + + let propositions = try XCTUnwrap(decisioning["propositions"] as? [[String: Any]]) + XCTAssertEqual(1, propositions.count) + XCTAssertEqual(proposition.uniqueId, propositions[0]["id"] as? String) + XCTAssertEqual(proposition.scope, propositions[0]["scope"] as? String) + assertExactMatch(expected: AnyCodable(proposition.scopeDetails), actual: AnyCodable(propositions[0]["scopeDetails"])) + + let items = try XCTUnwrap(propositions[0]["items"] as? [[String: Any]]) + XCTAssertEqual(1, items.count) + XCTAssertEqual(mockItemId, items[0]["id"] as? String) + + let itemCharacteristics = try XCTUnwrap(items[0]["characteristics"] as? [String: String]) + XCTAssertEqual(1, itemCharacteristics.count) + XCTAssertEqual("token1,token2", itemCharacteristics["tokens"]) + } + + func testPropositionItemGenerateInteractionXdmNoPropositionRef() throws { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + + // test + let xdm = propositionItem.generateInteractionXdm("buttonTap", withEdgeEventType: MessagingEdgeEventType.interact) + + // verify + XCTAssertNil(xdm) + } + + func testPropositionItemTrack() throws { + // setup + let expectation = XCTestExpectation(description: "track should dispatch an event with expected data.") + expectation.assertForOverFulfill = true + + let testEventData: [String: Any] = [ + "trackpropositions": true, + "propositioninteraction": [ + "eventType": "decisioning.propositionDisplay", + "_experience": [ + "decisioning": [ + "propositionEventType": [ + "display": 1 + ], + "propositions": [ + [ + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": [ + "key": "value" + ], + "items": [ + [ + "id": "mockItemId" + ] + ] + ] + ] + ] + ] + ] + ] + + let testEvent = Event(name: "Track propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: testEventData) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: testEvent.type, source: testEvent.source) { event in + // verify + XCTAssertEqual(testEvent.name, event.name) + XCTAssertNotNil(event.data) + self.assertExactMatch(expected: AnyCodable(testEventData), actual: AnyCodable(event.data)) + + expectation.fulfill() + } + + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + let proposition = Proposition(uniqueId: mockPropositionId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propositionItem]) + + // test + proposition.items[0].track(withEdgeEventType: MessagingEdgeEventType.display) + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + func testPropositionItemTrackForTokens() throws { + // setup + let expectation = XCTestExpectation(description: "track should dispatch an event with expected data.") + expectation.assertForOverFulfill = true + + let testEventData: [String: Any] = [ + "trackpropositions": true, + "propositioninteraction": [ + "eventType": "decisioning.propositionInteract", + "_experience": [ + "decisioning": [ + "propositionEventType": [ + "interact": 1 + ], + "propositionAction": [ + "id": "buttonTap", + "label": "buttonTap" + ], + "propositions": [ + [ + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": [ + "key": "value" + ], + "items": [ + [ + "id": "mockItemId", + "characteristics": [ + "tokens": "token1" + ] + ] + ] + ] + ] + ] + ] + ] + ] + + let testEvent = Event(name: "Track propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: testEventData) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: testEvent.type, source: testEvent.source) { event in + // verify + XCTAssertEqual(testEvent.name, event.name) + XCTAssertNotNil(event.data) + self.assertExactMatch(expected: AnyCodable(testEventData), actual: AnyCodable(event.data)) + + expectation.fulfill() + } + + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + let proposition = Proposition(uniqueId: mockPropositionId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propositionItem]) + + // test + proposition.items[0].track("buttonTap", withEdgeEventType: MessagingEdgeEventType.interact, forTokens: ["token1"]) + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + func testPropositionItemTrackNoPropositionRef() throws { + // setup + let expectation = XCTestExpectation(description: "track should not dispatch an event with expected data, if proposition reference is not available.") + expectation.isInverted = true + + let testEventData: [String: Any] = [ + "trackpropositions": true, + "propositioninteraction": [ + "eventType": "decisioning.propositionDisplay", + "_experience": [ + "decisioning": [ + "propositionEventType": [ + "display": 1 + ], + "propositions": [ + [ + "id": "mockPropositionId", + "scope": "mockScope", + "scopeDetails": [ + "key": "value" + ], + "items": [ + [ + "id": "mockItemId" + ] + ] + ] + ] + ] + ] + ] + ] + + let testEvent = Event(name: "Track propositions", + type: "com.adobe.eventType.messaging", + source: "com.adobe.eventSource.requestContent", + data: testEventData) + + EventHub.shared.getExtensionContainer(MockExtension.self)?.eventListeners.clear() + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: testEvent.type, source: testEvent.source) { event in + // verify + expectation.fulfill() + } + + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionHtmlContent") + let propositionItem = PropositionItem(itemId: mockItemId, schema: .htmlContent, itemData: mockCodeBasedContent) + + // test + propositionItem.track(withEdgeEventType: MessagingEdgeEventType.display) + wait(for: [expectation], timeout: ASYNC_TIMEOUT) + } + + // MARK: Helper functions + private func registerMockExtension(_ type: T.Type) { + let semaphore = DispatchSemaphore(value: 0) + EventHub.shared.registerExtension(type) { error in + XCTAssertNil(error) + semaphore.signal() + } + semaphore.wait() + } +} + diff --git a/AEPMessaging/Tests/UnitTests/PropositionPayloadTests.swift b/AEPMessaging/Tests/UnitTests/PropositionPayloadTests.swift index 3150ab18..f57ec07b 100644 --- a/AEPMessaging/Tests/UnitTests/PropositionPayloadTests.swift +++ b/AEPMessaging/Tests/UnitTests/PropositionPayloadTests.swift @@ -15,8 +15,9 @@ import XCTest @testable import AEPMessaging import AEPServices +import AEPTestUtils -class PropositionPayloadTests: XCTestCase { +class PropositionPayloadTests: XCTestCase, AnyCodableAsserts { let mockId = "mockId" let mockScope = "mockScope" let mockCorrelationId = "mockCorrelationId" @@ -25,25 +26,24 @@ class PropositionPayloadTests: XCTestCase { let mockDataId = "mockDataId" let mockDataContent = "mockDataContent" - var mockItemData: ItemData! let mockItemId = "mockItemId" - let mockItemSchema = "mockItemSchema" - var mockPayloadItem: PayloadItem! - var mockItems: [PayloadItem]! + let mockItemSchema: SchemaType = .htmlContent + var mockPropositionItem: PropositionItem! + var mockItems: [PropositionItem]! override func setUp() { mockScopeDetails = [ "correlationID": AnyCodable(mockCorrelationId) ] - mockItemData = ItemData(id: mockDataId, content: mockDataContent) - mockPayloadItem = PayloadItem(id: mockItemId, schema: mockItemSchema, data: mockItemData) - mockItems = [mockPayloadItem] + mockPropositionItem = PropositionItem(itemId: mockItemId, schema: mockItemSchema, itemData: ["content": mockDataContent]) + + mockItems = [mockPropositionItem] } func getDecodedPropositionPayload(fromString: String? = nil) -> PropositionPayload? { let decoder = JSONDecoder() - let propositionPayloadString = fromString ?? "{\"id\":\"\(mockId)\",\"scope\":\"\(mockScope)\",\"scopeDetails\":{\"correlationID\":\"\(mockCorrelationId)\"},\"items\":[{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockItemSchema)\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}]}" + let propositionPayloadString = fromString ?? "{\"id\":\"\(mockId)\",\"scope\":\"\(mockScope)\",\"scopeDetails\":{\"correlationID\":\"\(mockCorrelationId)\"},\"items\":[{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockItemSchema.toString())\",\"data\":{\"id\":\"\(mockDataId)\",\"content\":\"\(mockDataContent)\"}}]}" let propositionPayloadData = propositionPayloadString.data(using: .utf8)! @@ -69,10 +69,9 @@ class PropositionPayloadTests: XCTestCase { XCTAssertEqual(mockScopeDetails, propositionPayload.propositionInfo.scopeDetails) XCTAssertEqual(1, propositionPayload.items.count) let decodedItem = propositionPayload.items.first! - XCTAssertEqual(mockItemId, decodedItem.id) + XCTAssertEqual(mockItemId, decodedItem.itemId) XCTAssertEqual(mockItemSchema, decodedItem.schema) - XCTAssertEqual(mockDataId, decodedItem.data.id) - XCTAssertEqual(mockDataContent, decodedItem.data.content) + XCTAssertEqual(mockDataContent, decodedItem.htmlContent) } func testIsEncodable() throws { @@ -82,6 +81,7 @@ class PropositionPayloadTests: XCTestCase { return } let encoder = JSONEncoder() + let expected = "{\"id\":\"mockId\",\"scope\":\"mockScope\",\"scopeDetails\":{\"correlationID\":\"mockCorrelationId\"},\"items\":[{\"id\":\"mockItemId\",\"schema\":\"https://ns.adobe.com/personalization/html-content-item\",\"data\":{\"id\":\"mockDataId\",\"content\":\"mockDataContent\"}}]}".toAnyCodable() ?? "fail" // test guard let encodedPropositionPayload = try? encoder.encode(propositionPayload) else { @@ -90,7 +90,8 @@ class PropositionPayloadTests: XCTestCase { } // verify - XCTAssertEqual("{\"id\":\"mockId\",\"scope\":\"mockScope\",\"scopeDetails\":{\"correlationID\":\"mockCorrelationId\"},\"items\":[{\"id\":\"mockItemId\",\"schema\":\"mockItemSchema\",\"data\":{\"id\":\"mockDataId\",\"content\":\"mockDataContent\"}}]}", String(data: encodedPropositionPayload, encoding: .utf8)) + let actual = String(data: encodedPropositionPayload, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) } // MARK: - Exception path diff --git a/AEPMessaging/Tests/UnitTests/PropositionTests.swift b/AEPMessaging/Tests/UnitTests/PropositionTests.swift new file mode 100644 index 00000000..0b50ea71 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/PropositionTests.swift @@ -0,0 +1,115 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class PropositionTests: XCTestCase, AnyCodableAsserts { + + let mockScope = "mockScope" + let mockPropositionId = "mockPropositionId" + let mockItemId = "mockItemId" + let mockHtmlSchema: SchemaType = .htmlContent + let mockJsonSchema: SchemaType = .jsonContent + let mockScopeDetails: [String: Any] = ["key":"value"] + + func getDecodedObject(fromString: String) -> Proposition? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let proposition = try? decoder.decode(Proposition.self, from: objectData) else { + return nil + } + return proposition + } + + func testPropositionInit() { + // setup + let mockCodeBasedContent = JSONFileLoader.getRulesJsonFromFile("codeBasedPropositionJsonContent") + + // test + let propositionItem = PropositionItem(itemId: mockItemId, schema: .jsonContent, itemData: mockCodeBasedContent) + let proposition = Proposition(uniqueId: mockPropositionId, scope: mockScope, scopeDetails: mockScopeDetails, items: [propositionItem]) + + // verify + XCTAssertEqual(mockPropositionId, proposition.uniqueId) + XCTAssertEqual(mockScope, proposition.scope) + assertEqual(expected: AnyCodable(mockScopeDetails), actual: AnyCodable(proposition.scopeDetails)) + XCTAssertEqual(1, proposition.items.count) + let propItem = proposition.items[0] + XCTAssertEqual(mockItemId, propItem.itemId) + XCTAssertEqual(mockJsonSchema, propItem.schema) + assertExactMatch(expected: AnyCodable(mockCodeBasedContent["content"] as? [String: Any]), actual: AnyCodable(propItem.jsonContentDictionary)) + } + + func testPropositionIsDecodable() { + // setup + let mockCodeBasedContent = "My custom content" + let mockHtmlFormat: ContentType = .textHtml + let mockScopeDetailsString = "{\"key\":\"value\"}" + let propositionJsonString = "{\"id\":\"\(mockPropositionId)\",\"scope\":\"\(mockScope)\",\"scopeDetails\":\(mockScopeDetailsString),\"items\":[{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockHtmlSchema.toString())\",\"data\":{\"content\":\"\(mockCodeBasedContent)\",\"format\":\"\(mockHtmlFormat)\"}}]}" + + // test + guard let proposition = getDecodedObject(fromString: propositionJsonString) else { + XCTFail("Proposition object should be decodable.") + return + } + + // verify + XCTAssertEqual(mockPropositionId, proposition.uniqueId) + XCTAssertEqual(mockScope, proposition.scope) + assertExactMatch(expected: mockScopeDetailsString.toAnyCodable()!, actual: proposition.scopeDetails.toAnyCodable(), pathOptions: []) + XCTAssertEqual(1, proposition.items.count) + let propItem = proposition.items[0] + XCTAssertEqual(mockItemId, propItem.itemId) + XCTAssertEqual(mockHtmlSchema, propItem.schema) + XCTAssertEqual(mockCodeBasedContent, propItem.htmlContent) + } + + func testPropositionIsEncodable() { + // setup + let propositionJsonString = JSONFileLoader.getRulesStringFromFile("codeBasedPropositionHtml") + guard let proposition = getDecodedObject(fromString: propositionJsonString) else { + XCTFail("Proposition object should be decodable.") + return + } + + let encoder = JSONEncoder() + let expected = propositionJsonString.toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(proposition) else { + XCTFail("Proposition object should be encodable.") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testScopeDetailsIsRequired() throws { + // setup + let mockCodeBasedContent = "My custom content" + let mockHtmlFormat: ContentType = .textHtml + let propositionJsonString = "{\"id\":\"\(mockPropositionId)\",\"scope\":\"\(mockScope)\",\"items\":[{\"id\":\"\(mockItemId)\",\"schema\":\"\(mockHtmlSchema.toString())\",\"data\":{\"content\":\"\(mockCodeBasedContent)\",\"format\":\"\(mockHtmlFormat)\"}}]}" + + // test + let proposition = getDecodedObject(fromString: propositionJsonString) + + // verify + XCTAssertNil(proposition) + } +} diff --git a/AEPMessaging/Tests/UnitTests/RuleConsequence+MessagingTests.swift b/AEPMessaging/Tests/UnitTests/RuleConsequence+MessagingTests.swift new file mode 100644 index 00000000..e0c62045 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/RuleConsequence+MessagingTests.swift @@ -0,0 +1,73 @@ +/* + Copyright 2023 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 Foundation +import XCTest + +@testable import AEPMessaging +@testable import AEPCore + +class RuleConsequenceMessagingTests: XCTestCase { + + let SCHEMA_FEED_ITEM = "https://ns.adobe.com/personalization/message/feed-item" + let SCHEMA_IAM = "https://ns.adobe.com/personalization/message/in-app" + + func testIsFeedItemTrue() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "schema": SCHEMA_FEED_ITEM ]) + + // verify + XCTAssertTrue(consequence.isFeedItem) + } + + func testIsFeedItemFalse() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "schema": "not a feed" ]) + + // verify + XCTAssertFalse(consequence.isFeedItem) + } + + func testIsInAppTrueSchema() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "schema": SCHEMA_IAM ]) + + // verify + XCTAssertTrue(consequence.isInApp) + } + + func testIsInAppFalse() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "schema": "not an iam" ]) + + // verify + XCTAssertFalse(consequence.isInApp) + } + + func testWhenDetailsSchemaIsNotAString() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "schema": 552 ]) + + // verify + XCTAssertFalse(consequence.isInApp) + XCTAssertFalse(consequence.isFeedItem) + } + + func testWhenDetailsSchemaIsNotPresent() throws { + // setup + let consequence = RuleConsequence(id: "id", type: "type", details: [ "not a schema key": SCHEMA_IAM ]) + + // verify + XCTAssertFalse(consequence.isInApp) + XCTAssertFalse(consequence.isFeedItem) + } +} diff --git a/AEPMessaging/Tests/UnitTests/SurfaceTests.swift b/AEPMessaging/Tests/UnitTests/SurfaceTests.swift new file mode 100644 index 00000000..5c24f157 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/SurfaceTests.swift @@ -0,0 +1,79 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices + +class SurfaceTests: XCTestCase { + + let mockAppSurface = "mobileapp://com.adobe.ajo.e2eTestApp" + + func testSurfaceHappy() { + // test + let surface = Surface(path: "myTestView") + + // verify + XCTAssertEqual("\(mockAppSurface)/myTestView", surface.uri) + XCTAssertTrue(surface.isValid) + } + + func testSurfaceUriHappy() { + let myAppSurface = "myProtocol://com.my.bundle.id/myView" + + // test + let surface = Surface(uri: myAppSurface) + + // verify + XCTAssertEqual(myAppSurface, surface.uri) + XCTAssertTrue(surface.isValid) + } + + func testSurfaceEmptyPath() { + // test + let surface = Surface(path: "") + + // verify + XCTAssertEqual(mockAppSurface, surface.uri) + } + + func testSurfaceIsEqual() { + // test + let surface1 = Surface() + let surface2 = Surface() + + // verify + XCTAssertTrue(surface1.isEqual(surface2)) + XCTAssertEqual(surface1, surface2) + } + + func testSurfaceIsEqualDifferentSurfaces() { + // test + let surface1 = Surface(path: "myTestView") + let surface2 = Surface() + + // verify + XCTAssertFalse(surface1.isEqual(surface2)) + XCTAssertNotEqual(surface1, surface2) + } + + func testSurfaceIsEqualNotValidSurface() { + // test + let surface = Surface() + let notASurface = ["path": "myTestView"] as? Any + + // verify + XCTAssertFalse(surface.isEqual(notASurface)) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/ContentTypeTests.swift b/AEPMessaging/Tests/UnitTests/schemas/ContentTypeTests.swift new file mode 100644 index 00000000..3c8b1648 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/ContentTypeTests.swift @@ -0,0 +1,104 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices + +class ContentTypeTests: XCTestCase { + func testApplicationJson() throws { + // setup + let value = ContentType(rawValue: 0) + + // verify + XCTAssertEqual(value, .applicationJson) + XCTAssertEqual("application/json", value?.toString()) + } + + func testTextHtml() throws { + // setup + let value = ContentType(rawValue: 1) + + // verify + XCTAssertEqual(value, .textHtml) + XCTAssertEqual("text/html", value?.toString()) + } + + func testTextXml() throws { + // setup + let value = ContentType(rawValue: 2) + + // verify + XCTAssertEqual(value, .textXml) + XCTAssertEqual("text/xml", value?.toString()) + } + + func testTextPlain() throws { + // setup + let value = ContentType(rawValue: 3) + + // verify + XCTAssertEqual(value, .textPlain) + XCTAssertEqual("text/plain", value?.toString()) + } + + func testUnknown() throws { + // setup + let value = ContentType(rawValue: 4) + + // verify + XCTAssertEqual(value, .unknown) + XCTAssertEqual("", value?.toString()) + } + + func testInitFromStringApplicationJson() throws { + // setup + let value = ContentType(from: "application/json") + + // verify + XCTAssertEqual(.applicationJson, value) + } + + func testInitFromStringTextHtml() throws { + // setup + let value = ContentType(from: "text/html") + + // verify + XCTAssertEqual(.textHtml, value) + } + + func testInitFromStringTextXml() throws { + // setup + let value = ContentType(from: "text/xml") + + // verify + XCTAssertEqual(.textXml, value) + } + + func testInitFromStringTextPlain() throws { + // setup + let value = ContentType(from: "text/plain") + + // verify + XCTAssertEqual(.textPlain, value) + } + + func testInitFromStringUnknown() throws { + // setup + let value = ContentType(from: "i don't match anything") + + // verify + XCTAssertEqual(.unknown, value) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/FeedItemSchemaDataTests.swift b/AEPMessaging/Tests/UnitTests/schemas/FeedItemSchemaDataTests.swift new file mode 100644 index 00000000..49c03d65 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/FeedItemSchemaDataTests.swift @@ -0,0 +1,211 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class FeedItemSchemaDataTests: XCTestCase, AnyCodableAsserts { + + let mockExpiry = 1723163897 + let mockPublished = 1691541497 + let mockContentType = ContentType.applicationJson + let mockContentKey = "contentKey" + let mockContentValue = "value" + let mockMetaKey = "metaKey" + let mockMetaValue = "value" + + func getDecodedFeedItem(fromString: String) -> FeedItemSchemaData? { + let decoder = JSONDecoder() + let feedItemData = fromString.data(using: .utf8)! + guard let feedItem = try? decoder.decode(FeedItemSchemaData.self, from: feedItemData) else { + return nil + } + return feedItem + } + + func testIsDecodable() throws { + // test + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":{\"\(mockContentKey)\":\"\(mockContentValue)\"},\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItem = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockExpiry, feedItem.expiryDate) + XCTAssertEqual(mockPublished, feedItem.publishedDate) + XCTAssertEqual(mockContentType, feedItem.contentType) + let content = feedItem.content as? [String: String] + XCTAssertEqual(mockContentValue, content?[mockContentKey]) + XCTAssertEqual(mockMetaValue, feedItem.meta?[mockMetaKey] as? String) + } + + func testIsDecodableStringContent() throws { + // test + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":\"\(mockContentValue)\",\"contentType\":\"\(ContentType.textPlain.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItem = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockExpiry, feedItem.expiryDate) + XCTAssertEqual(mockPublished, feedItem.publishedDate) + XCTAssertEqual(.textPlain, feedItem.contentType) + XCTAssertEqual(mockContentValue, feedItem.content as? String) + XCTAssertEqual(mockMetaValue, feedItem.meta?[mockMetaKey] as? String) + } + + func testIsDecodableBadJson() throws { + // test + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":\"I AM NOT JSON\",\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}" + let feedItem = getDecodedFeedItem(fromString: feedJson) + + // verify + XCTAssertNil(feedItem) + } + + func testIsEncodable() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":{\"\(mockContentKey)\":\"\(mockContentValue)\"},\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItem = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + let encoder = JSONEncoder() + let expected = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":{\"\(mockContentKey)\":\"\(mockContentValue)\"},\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}".toAnyCodable() ?? "fail" + + // test + guard let encodedFeedItem = try? encoder.encode(feedItem) else { + XCTFail("unable to encode FeedItemSchemaData") + return + } + + // verify + let actual = String(data: encodedFeedItem, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testIsEncodableStringContent() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":\"\(mockContentValue)\",\"contentType\":\"\(ContentType.textPlain.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItem = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + let encoder = JSONEncoder() + let expected = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":\"\(mockContentValue)\",\"contentType\":\"\(ContentType.textPlain.toString())\",\"publishedDate\":\(mockPublished)}".toAnyCodable() ?? "fail" + + // test + guard let encodedFeedItem = try? encoder.encode(feedItem) else { + XCTFail("unable to encode FeedItemSchemaData") + return + } + + // verify + let actual = String(data: encodedFeedItem, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + // Exception paths + func testContentIsRequired() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}" + + + // test + let feedItem = getDecodedFeedItem(fromString: feedJson) + + // verify + XCTAssertNil(feedItem) + } + + func testContentTypeIsRequired() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":{\"\(mockContentKey)\":\"\(mockContentValue)\"},\"publishedDate\":\(mockPublished)}" + + + // test + let feedItem = getDecodedFeedItem(fromString: feedJson) + + // verify + XCTAssertNil(feedItem) + } + + func testPublishedDateExpiryDateMetaAreOptional() throws { + // setup + let feedJson = "{\"content\":{\"\(mockContentKey)\":\"\(mockContentValue)\"},\"contentType\":\"\(mockContentType.toString())\"}" + + + // test + let feedItem = getDecodedFeedItem(fromString: feedJson) + + // verify + XCTAssertNotNil(feedItem) + XCTAssertEqual(mockContentType, feedItem?.contentType) + let content = feedItem?.content as? [String: String] + XCTAssertEqual(mockContentValue, content?[mockContentKey]) + } + + // GetFeedItem + func testGetFeedItemHappy() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":{\"title\":\"fiTitle\",\"body\":\"fiBody\"},\"contentType\":\"\(mockContentType.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItemSchemaData = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + + // test + let result = feedItemSchemaData.getFeedItem() + + // verify + XCTAssertNotNil(result) + XCTAssertEqual("fiTitle", result?.title) + XCTAssertEqual("fiBody", result?.body) + } + + func testGetFeedItemNotApplicationJson() throws { + // setup + let feedJson = "{\"expiryDate\":\(mockExpiry),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"content\":\"thisIsContent\",\"contentType\":\"\(ContentType.textPlain.toString())\",\"publishedDate\":\(mockPublished)}" + guard let feedItemSchemaData = getDecodedFeedItem(fromString: feedJson) else { + XCTFail("unable to decode feedJson") + return + } + + // test + let result = feedItemSchemaData.getFeedItem() + + // verify + XCTAssertNil(result) + } + + // TEST HELPER + func testGetEmpty() throws { + // test + let result = FeedItemSchemaData.getEmpty() + + // verify + XCTAssertNotNil(result) + XCTAssertEqual("plain-text content", result.content as? String) + XCTAssertEqual(.textPlain, result.contentType) + XCTAssertNil(result.publishedDate) + XCTAssertNil(result.expiryDate) + XCTAssertNil(result.meta) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/HtmlContentSchemaDataTests.swift b/AEPMessaging/Tests/UnitTests/schemas/HtmlContentSchemaDataTests.swift new file mode 100644 index 00000000..8a6d3084 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/HtmlContentSchemaDataTests.swift @@ -0,0 +1,94 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class HtmlContentSchemaDataTests: XCTestCase, AnyCodableAsserts { + + let mockContent = "this is some html" + let mockFormat = ContentType.textHtml + + func getDecodedObject(fromString: String) -> HtmlContentSchemaData? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let object = try? decoder.decode(HtmlContentSchemaData.self, from: objectData) else { + return nil + } + return object + } + + func testIsDecodable() throws { + // setup + let json = "{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + XCTAssertEqual(mockContent, decodedObject.content) + XCTAssertEqual(mockFormat, decodedObject.format) + } + + func testIsEncodable() throws { + // setup + let json = "{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":\"\(mockContent)\",\"format\":\"\(mockFormat.toString())\"}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testContentIsRequired() throws { + // setup + let json = "{\"format\":\"\(mockFormat.toString())\"}" + + // test + let object = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(object) + } + + func testFormatIsOptional() throws { + // setup + let json = "{\"content\":\"\(mockContent)\"}" + + // test + let object = getDecodedObject(fromString: json) + + // verify + XCTAssertNotNil(object) + XCTAssertEqual(mockContent, object?.content) + XCTAssertEqual(.textHtml, object?.format) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/InAppSchemaDataTests.swift b/AEPMessaging/Tests/UnitTests/schemas/InAppSchemaDataTests.swift new file mode 100644 index 00000000..87ca4ecd --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/InAppSchemaDataTests.swift @@ -0,0 +1,339 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class InAppSchemaDataTests: XCTestCase, AnyCodableAsserts { + + let mockContentJson = "{\"key\":\"value\"}" + let mockContentString = "contentString" + let mockPublishedDate = 123456789 + let mockExpiryDate = 234567890 + let mockMetaKey = "metaKey" + let mockMetaValue = "metaValue" + let mockMobileParamsKey = "mobKey" + let mockMobileParamsValue = "mobValue" + let mockWebParamsKey = "webKey" + let mockWebParamsValue = "webValue" + let mockRemoteAsset = "https://somedomain.com/someimage.jpg" + + func getDecodedObject(fromString: String) -> InAppSchemaData? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let object = try? decoder.decode(InAppSchemaData.self, from: objectData) else { + return nil + } + return object + } + + // MARK: - codable tests + + func testIsDecodableJsonObject() throws { + // setup + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + let contentDictionary = decodedObject.content as? [String: Any] + XCTAssertEqual("value", contentDictionary?["key"] as? String) + XCTAssertEqual(.applicationJson, decodedObject.contentType) + XCTAssertEqual(mockPublishedDate, decodedObject.publishedDate) + XCTAssertEqual(mockExpiryDate, decodedObject.expiryDate) + XCTAssertEqual(mockMetaValue, decodedObject.meta?[mockMetaKey] as? String) + XCTAssertEqual(mockMobileParamsValue, decodedObject.mobileParameters?[mockMobileParamsKey] as? String) + XCTAssertEqual(mockWebParamsValue, decodedObject.webParameters?[mockWebParamsKey] as? String) + XCTAssertEqual(mockRemoteAsset, decodedObject.remoteAssets?.first) + } + + func testIsDecodableJsonArray() throws { + // setup + let json = "{\"content\":[\(mockContentJson)],\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + let contentArray = decodedObject.content as? [[String: Any]] + let contentDictionary = contentArray?.first + XCTAssertEqual("value", contentDictionary?["key"] as? String) + XCTAssertEqual(.applicationJson, decodedObject.contentType) + XCTAssertEqual(mockPublishedDate, decodedObject.publishedDate) + XCTAssertEqual(mockExpiryDate, decodedObject.expiryDate) + XCTAssertEqual(mockMetaValue, decodedObject.meta?[mockMetaKey] as? String) + XCTAssertEqual(mockMobileParamsValue, decodedObject.mobileParameters?[mockMobileParamsKey] as? String) + XCTAssertEqual(mockWebParamsValue, decodedObject.webParameters?[mockWebParamsKey] as? String) + XCTAssertEqual(mockRemoteAsset, decodedObject.remoteAssets?.first) + } + + func testIsDecodableString() throws { + // setup + let json = "{\"content\":\"\(mockContentString)\",\"contentType\":\"\(ContentType.textHtml.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + XCTAssertEqual(mockContentString, decodedObject.content as? String) + XCTAssertEqual(.textHtml, decodedObject.contentType) + XCTAssertEqual(mockPublishedDate, decodedObject.publishedDate) + XCTAssertEqual(mockExpiryDate, decodedObject.expiryDate) + XCTAssertEqual(mockMetaValue, decodedObject.meta?[mockMetaKey] as? String) + XCTAssertEqual(mockMobileParamsValue, decodedObject.mobileParameters?[mockMobileParamsKey] as? String) + XCTAssertEqual(mockWebParamsValue, decodedObject.webParameters?[mockWebParamsKey] as? String) + XCTAssertEqual(mockRemoteAsset, decodedObject.remoteAssets?.first) + } + + func testIsEncodableJsonObjectContent() throws { + // setup + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testIsEncodableJsonArrayContent() throws { + // setup + let json = "{\"content\":[\(mockContentJson)],\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":[\(mockContentJson)],\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testIsEncodableStringContent() throws { + // setup + let json = "{\"content\":\"\(mockContentString)\",\"contentType\":\"\(ContentType.textHtml.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":\"\(mockContentString)\",\"contentType\":\"\(ContentType.textHtml.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + // MARK: - required vs. not required properties + + func testContentIsRequired() throws { + // setup + let json = "{\"contentType\":\"\(ContentType.applicationJson.toString())\",\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + + // test + let decodedObject = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(decodedObject) + } + + func testContentTypeIsRequired() throws { + // setup + let json = "{\"content\":\(mockContentJson),\"publishedDate\":\(mockPublishedDate),\"expiryDate\":\(mockExpiryDate),\"meta\":{\"\(mockMetaKey)\":\"\(mockMetaValue)\"},\"mobileParameters\":{\"\(mockMobileParamsKey)\":\"\(mockMobileParamsValue)\"},\"webParameters\":{\"\(mockWebParamsKey)\":\"\(mockWebParamsValue)\"},\"remoteAssets\":[\"\(mockRemoteAsset)\"]}" + + // test + let decodedObject = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(decodedObject) + } + + func testOnlyContentAndContentTypeAreRequired() throws { + // setup + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\"}" + + // test + let decodedObject = getDecodedObject(fromString: json) + + // verify + XCTAssertNotNil(decodedObject) + let contentDictionary = decodedObject?.content as? [String: Any] + XCTAssertEqual("value", contentDictionary?["key"] as? String) + XCTAssertEqual(.applicationJson, decodedObject?.contentType) + XCTAssertNil(decodedObject?.publishedDate) + XCTAssertNil(decodedObject?.expiryDate) + XCTAssertNil(decodedObject?.meta) + XCTAssertNil(decodedObject?.mobileParameters) + XCTAssertNil(decodedObject?.webParameters) + XCTAssertNil(decodedObject?.remoteAssets) + } + + // MARK: - getMessageSettings + + /// sample `mobileParameters` json which gets represented by a `MessageSettings` object: + /// { + /// "mobileParameters": { + /// "schemaVersion": "1.0", + /// "width": 80, + /// "height": 50, + /// "verticalAlign": "center", + /// "verticalInset": 0, + /// "horizontalAlign": "center", + /// "horizontalInset": 0, + /// "uiTakeover": true, + /// "displayAnimation": "top", + /// "dismissAnimation": "top", + /// "backdropColor": "000000", // RRGGBB + /// "backdropOpacity: 0.3, + /// "cornerRadius": 15, + /// "gestures": { + /// "swipeUp": "adbinapp://dismiss", + /// "swipeDown": "adbinapp://dismiss", + /// "swipeLeft": "adbinapp://dismiss?interaction=negative", + /// "swipeRight": "adbinapp://dismiss?interaction=positive", + /// "tapBackground": "adbinapp://dismiss" + /// } + /// } + /// } + + func testGetMessageSettingsHappy() throws { + // setup + let testMobileParameters = "{\"schemaVersion\":\"1.0\",\"width\":80,\"height\":50,\"verticalAlign\":\"center\",\"verticalInset\":0,\"horizontalAlign\":\"center\",\"horizontalInset\":0,\"uiTakeover\":true,\"displayAnimation\":\"top\",\"dismissAnimation\":\"top\",\"backdropColor\":\"000000\",\"backdropOpacity\":0.3,\"cornerRadius\":15,\"gestures\":{\"swipeUp\":\"adbinapp://dismiss?interaction=swipeUp\",\"swipeDown\":\"adbinapp://dismiss?interaction=swipeDown\",\"swipeLeft\":\"adbinapp://dismiss?interaction=swipeLeft\",\"swipeRight\":\"adbinapp://dismiss?interaction=swipeRight\",\"tapBackground\":\"adbinapp://dismiss?interaction=tapBackground\"}}" + + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\",\"mobileParameters\":\(testMobileParameters)}" + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // test + let result = decodedObject.getMessageSettings(with: self) + + // verify + XCTAssertEqual(self, result.parent as? InAppSchemaDataTests) + XCTAssertEqual(80, result.width) + XCTAssertEqual(50, result.height) + XCTAssertEqual(.center, result.verticalAlign) + XCTAssertEqual(0, result.verticalInset) + XCTAssertEqual(.center, result.horizontalAlign) + XCTAssertEqual(0, result.horizontalInset) + XCTAssertEqual(true, result.uiTakeover) + XCTAssertEqual(.top, result.displayAnimation) + XCTAssertEqual(.top, result.dismissAnimation) + XCTAssertEqual(UIColor(hue: 0, saturation: 0, brightness: 0, alpha: 0.3), result.getBackgroundColor()) // 000000 color and 0.3 opacity + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeUp"), result.gestures?[.swipeUp]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeDown"), result.gestures?[.swipeDown]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeLeft"), result.gestures?[.swipeLeft]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeRight"), result.gestures?[.swipeRight]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=tapBackground"), result.gestures?[.tapBackground]) + } + + func testGetMessageSettingsNoMobileParameters() throws { + // setup + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\"}" + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // test + let result = decodedObject.getMessageSettings(with: self) + + // verify + XCTAssertEqual(self, result.parent as? InAppSchemaDataTests) + XCTAssertNil(result.width) + XCTAssertNil(result.width) + XCTAssertNil(result.height) + XCTAssertNil(result.verticalAlign) + XCTAssertNil(result.verticalInset) + XCTAssertNil(result.horizontalAlign) + XCTAssertNil(result.horizontalInset) + XCTAssertNil(result.uiTakeover) + XCTAssertNil(result.displayAnimation) + XCTAssertNil(result.dismissAnimation) + XCTAssertEqual(UIColor(red: 1, green: 1, blue: 1, alpha: 0), result.getBackgroundColor()) // default color + XCTAssertNil(result.gestures) + } + + func testGetMessageSettingsDefaultValues() throws { + // setup + let testMobileParameters = "{\"schemaVersion\":\"1.0\",\"width\":80,\"height\":50,\"verticalInset\":0,\"horizontalInset\":0,\"backdropColor\":\"000000\",\"backdropOpacity\":0.3,\"cornerRadius\":15,\"gestures\":{\"swipeUp\":\"adbinapp://dismiss?interaction=swipeUp\",\"swipeDown\":\"adbinapp://dismiss?interaction=swipeDown\",\"swipeLeft\":\"adbinapp://dismiss?interaction=swipeLeft\",\"swipeRight\":\"adbinapp://dismiss?interaction=swipeRight\",\"tapBackground\":\"adbinapp://dismiss?interaction=tapBackground\"}}" + + let json = "{\"content\":\(mockContentJson),\"contentType\":\"\(ContentType.applicationJson.toString())\",\"mobileParameters\":\(testMobileParameters)}" + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // test + let result = decodedObject.getMessageSettings(with: self) + + // verify + XCTAssertEqual(self, result.parent as? InAppSchemaDataTests) + XCTAssertEqual(80, result.width) + XCTAssertEqual(50, result.height) + XCTAssertEqual(.center, result.verticalAlign) + XCTAssertEqual(0, result.verticalInset) + XCTAssertEqual(.center, result.horizontalAlign) + XCTAssertEqual(0, result.horizontalInset) + XCTAssertEqual(true, result.uiTakeover) + XCTAssertEqual(MessageAnimation.none, result.displayAnimation) + XCTAssertEqual(MessageAnimation.none, result.dismissAnimation) + XCTAssertEqual(UIColor(hue: 0, saturation: 0, brightness: 0, alpha: 0.3), result.getBackgroundColor()) // 000000 color and 0.3 opacity + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeUp"), result.gestures?[.swipeUp]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeDown"), result.gestures?[.swipeDown]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeLeft"), result.gestures?[.swipeLeft]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=swipeRight"), result.gestures?[.swipeRight]) + XCTAssertEqual(URL(string: "adbinapp://dismiss?interaction=tapBackground"), result.gestures?[.tapBackground]) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/JsonContentSchemaDataTests.swift b/AEPMessaging/Tests/UnitTests/schemas/JsonContentSchemaDataTests.swift new file mode 100644 index 00000000..e9d28e07 --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/JsonContentSchemaDataTests.swift @@ -0,0 +1,137 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class JsonContentSchemaDataTests: XCTestCase, AnyCodableAsserts { + + let mockJsonObjectContent = "{\"key\":\"value\"}" + let mockJsonArrayContent = "[\"content\",\"moreContent\"]" + let mockFormat = ContentType.applicationJson + + func getDecodedObject(fromString: String) -> JsonContentSchemaData? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let object = try? decoder.decode(JsonContentSchemaData.self, from: objectData) else { + return nil + } + return object + } + + func testIsDecodableJsonObject() throws { + // setup + let json = "{\"content\":\(mockJsonObjectContent),\"format\":\"\(mockFormat.toString())\"}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + let dictionaryValue = decodedObject.getDictionaryValue + XCTAssertEqual("value", dictionaryValue?["key"] as? String) + XCTAssertEqual(mockFormat, decodedObject.format) + } + + func testIsDecodableJsonArray() throws { + // setup + let json = "{\"content\":\(mockJsonArrayContent),\"format\":\"\(mockFormat.toString())\"}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + let arrayValue = decodedObject.getArrayValue + XCTAssertEqual(2, arrayValue?.count) + XCTAssertEqual("content", arrayValue?[0] as? String) + XCTAssertEqual("moreContent", arrayValue?[1] as? String) + XCTAssertEqual(mockFormat, decodedObject.format) + } + + func testIsEncodableJsonObject() throws { + // setup + let json = "{\"content\":\(mockJsonObjectContent),\"format\":\"\(mockFormat.toString())\"}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":\(mockJsonObjectContent),\"format\":\"\(mockFormat.toString())\"}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testIsEncodableJsonArray() throws { + // setup + let json = "{\"content\":\(mockJsonArrayContent),\"format\":\"\(mockFormat.toString())\"}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"content\":\(mockJsonArrayContent),\"format\":\"\(mockFormat.toString())\"}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testContentIsRequired() throws { + // setup + let json = "{\"format\":\"\(mockFormat.toString())\"}" + + // test + let object = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(object) + } + + func testFormatIsOptional() throws { + // setup + let json = "{\"content\":\(mockJsonObjectContent)}" + + // test + let decodedObject = getDecodedObject(fromString: json) + + // verify + XCTAssertNotNil(decodedObject) + let dictionaryValue = decodedObject?.getDictionaryValue + XCTAssertEqual("value", dictionaryValue?["key"] as? String) + XCTAssertEqual(mockFormat, decodedObject?.format) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/RulesetSchemaDataTests.swift b/AEPMessaging/Tests/UnitTests/schemas/RulesetSchemaDataTests.swift new file mode 100644 index 00000000..0701fa7e --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/RulesetSchemaDataTests.swift @@ -0,0 +1,94 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices +import AEPTestUtils + +class RulesetSchemaDataTests: XCTestCase, AnyCodableAsserts { + + let mockVersion = 1 + let mockRuleKey = "ruleKey" + let mockRuleValue = "ruleValue" + + func getDecodedObject(fromString: String) -> RulesetSchemaData? { + let decoder = JSONDecoder() + let objectData = fromString.data(using: .utf8)! + guard let object = try? decoder.decode(RulesetSchemaData.self, from: objectData) else { + return nil + } + return object + } + + func testIsDecodable() throws { + // setup + let json = "{\"version\":\(mockVersion),\"rules\":[{\"\(mockRuleKey)\":\"\(mockRuleValue)\"}]}" + + // test + guard let decodedObject = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + + // verify + XCTAssertNotNil(decodedObject) + XCTAssertEqual(mockVersion, decodedObject.version) + let dictionaryValue = decodedObject.rules.first + XCTAssertEqual(mockRuleValue, dictionaryValue?[mockRuleKey] as? String) + } + + func testIsEncodable() throws { + // setup + let json = "{\"version\":\(mockVersion),\"rules\":[{\"\(mockRuleKey)\":\"\(mockRuleValue)\"}]}" + guard let object = getDecodedObject(fromString: json) else { + XCTFail("unable to decode json") + return + } + let encoder = JSONEncoder() + let expected = "{\"version\":\(mockVersion),\"rules\":[{\"\(mockRuleKey)\":\"\(mockRuleValue)\"}]}".toAnyCodable() ?? "fail" + + // test + guard let encodedObject = try? encoder.encode(object) else { + XCTFail("unable to encode object") + return + } + + // verify + let actual = String(data: encodedObject, encoding: .utf8)?.toAnyCodable() ?? "" + assertExactMatch(expected: expected, actual: actual, pathOptions: []) + } + + func testVersionIsRequired() throws { + // setup + let json = "{\"rules\":[{\"\(mockRuleKey)\":\"\(mockRuleValue)\"}]}" + + // test + let object = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(object) + } + + func testRulesIsRequired() throws { + // setup + let json = "{\"version\":\(mockVersion)}" + + // test + let object = getDecodedObject(fromString: json) + + // verify + XCTAssertNil(object) + } +} diff --git a/AEPMessaging/Tests/UnitTests/schemas/SchemaTypeTests.swift b/AEPMessaging/Tests/UnitTests/schemas/SchemaTypeTests.swift new file mode 100644 index 00000000..8843099f --- /dev/null +++ b/AEPMessaging/Tests/UnitTests/schemas/SchemaTypeTests.swift @@ -0,0 +1,155 @@ +/* + 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 Foundation +import XCTest + +@testable import AEPMessaging +import AEPServices + +class SchemaTypeTests: XCTestCase { + func testUnknown() throws { + // setup + let value = SchemaType(rawValue: 0) + + // verify + XCTAssertEqual(value, .unknown) + XCTAssertEqual("", value?.toString()) + } + + func testInitFromSchemaUnknown() throws { + // test + let value = SchemaType(from: "this isn't a valid schema type") + + // verify + XCTAssertEqual(.unknown, value) + } + + func testHtmlContent() throws { + // setup + let value = SchemaType(rawValue: 1) + + // verify + XCTAssertEqual(value, .htmlContent) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.HTML_CONTENT, value?.toString()) + } + + func testInitFromSchemaHtmlContent() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.HTML_CONTENT) + + // verify + XCTAssertEqual(.htmlContent, value) + } + + func testJsonContent() throws { + // setup + let value = SchemaType(rawValue: 2) + + // verify + XCTAssertEqual(value, .jsonContent) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.JSON_CONTENT, value?.toString()) + } + + func testInitFromSchemaJsonContent() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.JSON_CONTENT) + + // verify + XCTAssertEqual(.jsonContent, value) + } + + func testRuleset() throws { + // setup + let value = SchemaType(rawValue: 3) + + // verify + XCTAssertEqual(value, .ruleset) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.RULESET_ITEM, value?.toString()) + } + + func testInitFromSchemaRulesetItem() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.RULESET_ITEM) + + // verify + XCTAssertEqual(.ruleset, value) + } + + func testInapp() throws { + // setup + let value = SchemaType(rawValue: 4) + + // verify + XCTAssertEqual(value, .inapp) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.IN_APP, value?.toString()) + } + + func testInitFromSchemaInapp() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.IN_APP) + + // verify + XCTAssertEqual(.inapp, value) + } + + func testFeed() throws { + // setup + let value = SchemaType(rawValue: 5) + + // verify + XCTAssertEqual(value, .feed) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.FEED_ITEM, value?.toString()) + } + + func testInitFromSchemaFeed() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.FEED_ITEM) + + // verify + XCTAssertEqual(.feed, value) + } + + func testNativeAlert() throws { + // setup + let value = SchemaType(rawValue: 6) + + // verify + XCTAssertEqual(value, .nativeAlert) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.NATIVE_ALERT, value?.toString()) + } + + func testInitFromSchemaNativeAlert() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.NATIVE_ALERT) + + // verify + XCTAssertEqual(.nativeAlert, value) + } + + func testDefaultContent() throws { + // setup + let value = SchemaType(rawValue: 7) + + // verify + XCTAssertEqual(value, .defaultContent) + XCTAssertEqual(MessagingConstants.PersonalizationSchemas.DEFAULT_CONTENT, value?.toString()) + } + + func testInitFromSchemaDefaultContent() throws { + // test + let value = SchemaType(from: MessagingConstants.PersonalizationSchemas.DEFAULT_CONTENT) + + // verify + XCTAssertEqual(.defaultContent, value) + } +} diff --git a/Documentation/README.md b/Documentation/README.md index 5ef774a5..e9f14995 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -2,6 +2,8 @@ ### Prerequisites +- Code-based Experiences + - [APIs usage](./sources/exd-cbe-beta/apis-usage.md) - Push Messaging - [Enable push notifications in your app](./sources/prerequisite/enable-push-notifications.md) - Push and In-app Messaging diff --git a/Documentation/assets/clone-the-repo.png b/Documentation/assets/clone-the-repo.png new file mode 100644 index 00000000..1a60e75b Binary files /dev/null and b/Documentation/assets/clone-the-repo.png differ diff --git a/Documentation/assets/configure-environment-file-id.png b/Documentation/assets/configure-environment-file-id.png new file mode 100644 index 00000000..6ce39f46 Binary files /dev/null and b/Documentation/assets/configure-environment-file-id.png differ diff --git a/Documentation/assets/configure-surface-path.png b/Documentation/assets/configure-surface-path.png new file mode 100644 index 00000000..ee7f842c Binary files /dev/null and b/Documentation/assets/configure-surface-path.png differ diff --git a/Documentation/assets/download-zip.png b/Documentation/assets/download-zip.png new file mode 100644 index 00000000..645baabe Binary files /dev/null and b/Documentation/assets/download-zip.png differ diff --git a/Documentation/assets/run-app.png b/Documentation/assets/run-app.png new file mode 100644 index 00000000..028129ca Binary files /dev/null and b/Documentation/assets/run-app.png differ diff --git a/Documentation/sources/exd-cbe-beta/apis-usage.md b/Documentation/sources/exd-cbe-beta/apis-usage.md new file mode 100644 index 00000000..bf2c2e8e --- /dev/null +++ b/Documentation/sources/exd-cbe-beta/apis-usage.md @@ -0,0 +1,212 @@ +# APIs Usage + +This document details the Messaging SDK APIs that can be used to implement code-based experiences in mobile apps. + +## Code-based experiences APIs + +- [updatePropositionsForSurfaces](#updatePropositionsForSurfaces) +- [getPropositionsForSurfaces](#getPropositionsForSurfaces) + +--- + +### updatePropositionsForSurfaces(_:) + +Dispatches an event for the Edge network extension to fetch personalization decisions from the AJO campaigns for the provided surfaces array. The returned decision propositions are cached in-memory by the Messaging extension. + +To retrieve previously cached decision propositions, use `getPropositionsForSurfaces(_:_:)` API. + +#### Swift + +##### Syntax +```swift +static func updatePropositionsForSurfaces(_ surfaces: [Surface]) +``` + +##### Example +```swift +let surface1 = Surface(path: "myView#button") +let surface2 = Surface(path: "myViewAttributes") + +Messaging.updatePropositionsForSurfaces([surface1, surface2]) +``` + +#### Objective-C + +##### Syntax +```objc ++ (void) updatePropositionsForSurfaces: (NSArray* _Nonnull) surfaces; +``` + +##### Example +```objc +AEPSurface* surface1 = [[AEPSurface alloc] initWithPath: @"myView#button"]; +AEPSurface* surface2 = [[AEPSurface alloc] initWithPath: @"myView#button"]; + +[AEPMobileMessaging updatePropositions: @[surface1, surface2]]; +``` + +--- + +### getPropositionsForSurfaces(_:_:) + +Retrieves the previously fetched propositions from the SDK's in-memory propositions cache for the provided surfaces. The completion handler is invoked with the decision propositions corresponding to the given surfaces or AEPError, if it occurs. + +If a requested surface was not previously cached prior to calling `getPropositionsForSurfaces(_:_:)` (using the `updatePropositionsForSurfaces(_:)` API), no propositions will be returned for that surface. + +#### Swift + +##### Syntax + +```swift +static func getPropositionsForSurfaces(_ surfacePaths: [Surface], _ completion: @escaping ([Surface: [MessagingProposition]]?, Error?) -> Void) +``` + +##### Example + +```swift +let surface1 = Surface(path: "myView#button") +let surface2 = Surface(path: "myViewAttributes") + +Messaging.getPropositionsForSurfaces([surface1, surface2]) { propositionsDict, error in + guard error == nil else { + // handle error + return + } + + guard let propositionsDict = propositionsDict else { + // bail early if no propositions + return + } + + // get the propositions for the given surfaces + if let propositions1 = propositionsDict[surface1] { + // read surface1 propositions + } + + if let propositions2 = propositionsDict[surface2] { + // read surface2 propositions + } +} +``` + +#### Objective-C + +##### Syntax + +```objc ++ (void) getPropositionsForSurfaces: (NSArray* _Nonnull) surfaces + completion: (void (^ _Nonnull)(NSDictionary*>* _Nullable propositionsDict, NSError* _Nullable error)) completion; +``` + +##### Example + +```objc +AEPSurface* surface1 = [[AEPSurface alloc] initWithPath: @"myView#button"]; +AEPSurface* surface2 = [[AEPSurface alloc] initWithPath: @"myView#button"]; + +[AEPMobileMessaging getPropositionsForSurfaces: @[surface1, surface2] + completion: ^(NSDictionary*>* propositionsDict, NSError* error) { + if (error != nil) { + // handle error + return; + } + + NSArray* proposition1 = propositionsDict[surface1]; + // read surface1 propositions + + NSArray* proposition2 = propositionsDict[surface2]; + // read surface2 propositions +}]; +``` + +--- + +## Public Classes + +| Type | Swift | Objective-C | +| ---- | ----- | ----------- | +| class | `Surface` | `AEPSurface` | +| class | `MessagingProposition` | `AEPMessagingProposition` | +| class | `MessagingPropositionItem` | `AEPMessagingPropositionItem` | + +### class Surface + +Represents an entity for user or system interaction. It is identified by a self-describing URI and is used to fetch the decision propositions from the AJO campaigns. For example, all mobile application surface URIs start with `mobileapp://`, followed by app bundle identifier and an optional path. + +#### Swift + +##### Syntax + +```swift +/// `Surface` class is used to create surfaces for requesting propositions in personalization query requests. +@objc(AEPSurface) +@objcMembers +public class Surface: NSObject, Codable { + /// Unique surface URI string + public let uri: String + + /// Creates a new surface by appending the given surface `path` to the mobile app surface prefix. + /// + /// - Parameter path: string representation for the surface path. + public init(path: String) { + guard !path.isEmpty else { + uri = "" + return + } + uri = Bundle.main.mobileappSurface + MessagingConstants.PATH_SEPARATOR + path + } + ... +} +``` + +##### Example + +```swift +// Creates a surface instance representing a banner within homeView view in my mobile application. +let surface = Surface(path: "homeView#banner") +``` + +### class MessagingProposition + +Represents the decision propositions received from the remote, upon a personalization query request to the Experience Edge network. + +```swift +@objc(AEPMessagingProposition) +@objcMembers +public class MessagingProposition: NSObject, Codable { + /// Unique proposition identifier + public let uniqueId: String + + /// Scope string + public let scope: String + + /// Scope details dictionary + var scopeDetails: [String: Any] + + /// Array containing proposition decision items + public lazy var items: [MessagingPropositionItem] = {...}() + + ... +} +``` + +### class MessagingPropositionItem + +Represents the decision proposition item received from the remote, upon a personalization query to the Experience Edge network. + +```swift +@objc(AEPMessagingPropositionItem) +@objcMembers +public class MessagingPropositionItem: NSObject, Codable { + /// Unique proposition item identifier + public let uniqueId: String + + /// Proposition item schema string + public let schema: String + + /// Proposition item content string + public let content: String + + ... +} +``` \ No newline at end of file diff --git a/Documentation/sources/getting-started.md b/Documentation/sources/getting-started.md index 4c84ef1c..2dec19c9 100644 --- a/Documentation/sources/getting-started.md +++ b/Documentation/sources/getting-started.md @@ -39,7 +39,7 @@ Alternatively, if your project has a `Package.swift` file, you can add AEPMessag ``` dependencies: [ - .package(url: "https://github.com/adobe/aepsdk-messaging-ios.git", .upToNextMajor(from: "4.0.0")) + .package(url: "https://github.com/adobe/aepsdk-messaging-ios.git", .upToNextMajor(from: "5.0.0")) ], targets: [ .target(name: "YourTarget", diff --git a/Gemfile b/Gemfile index fd2ba1ba..3759f261 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -gem 'cocoapods', '= 1.10.0' +gem 'cocoapods', '= 1.14.3' gem 'addressable', '>= 2.8.0' gem 'tzinfo', '>= 1.2.10' -gem 'cocoapods-downloader', '>= 1.6.2' +gem 'cocoapods-downloader', '>= 2.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 33e16efd..f82e90d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,95 +1,116 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.3) - activesupport (5.2.5) + CFPropertyList (3.0.7) + base64 + nkf + rexml + activesupport (7.1.3.2) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) - claide (1.0.3) - cocoapods (1.10.0) - addressable (~> 2.6) + base64 (0.2.0) + bigdecimal (3.1.6) + claide (1.1.0) + cocoapods (1.14.3) + addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.10.0) + cocoapods-core (= 1.14.3) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) fourflusher (>= 2.3.0, < 3.0) gh_inspector (~> 1.0) - molinillo (~> 0.6.6) + molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (~> 1.4) - xcodeproj (>= 1.19.0, < 2.0) - cocoapods-core (1.10.0) - activesupport (> 5.0, < 6) - addressable (~> 2.6) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.14.3) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) fuzzy_match (~> 2.0.4) nap (~> 1.0) netrc (~> 0.11) - public_suffix + public_suffix (~> 4.0) typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.6.3) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap - cocoapods-search (1.0.0) - cocoapods-trunk (1.5.0) + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.1.8) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + drb (2.2.0) + ruby2_keywords escape (0.0.4) - ethon (0.13.0) + ethon (0.16.0) ffi (>= 1.15.0) - ffi (1.15.0) + ffi (1.16.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) httpclient (2.8.3) - i18n (1.8.10) + i18n (1.14.1) concurrent-ruby (~> 1.0) - json (2.5.1) - minitest (5.14.4) - molinillo (0.6.6) + json (2.7.1) + minitest (5.22.2) + molinillo (0.8.0) + mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) + nkf (0.2.0) public_suffix (4.0.6) - ruby-macho (1.4.0) - thread_safe (0.3.6) - typhoeus (1.4.0) + rexml (3.2.6) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + typhoeus (1.4.1) ethon (>= 0.9.0) - tzinfo (1.2.9) - thread_safe (~> 0.1) - xcodeproj (1.19.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) + rexml (~> 3.2.4) PLATFORMS + arm64-darwin-22 x86_64-darwin-18 x86_64-darwin-19 x86_64-linux DEPENDENCIES - cocoapods (= 1.10.0) + addressable (>= 2.8.0) + cocoapods (= 1.14.3) + cocoapods-downloader (>= 2.1.0) + tzinfo (>= 1.2.10) BUNDLED WITH 2.2.16 diff --git a/Makefile b/Makefile index 9948a3be..87ccc0b7 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ SIMULATOR_ARCHIVE_PATH = $(CURRENT_DIRECTORY)/build/ios_simulator.xcarchive/Prod SIMULATOR_ARCHIVE_DSYM_PATH = $(CURRENT_DIRECTORY)/build/ios_simulator.xcarchive/dSYMs/ IOS_ARCHIVE_PATH = $(CURRENT_DIRECTORY)/build/ios.xcarchive/Products/Library/Frameworks/ IOS_ARCHIVE_DSYM_PATH = $(CURRENT_DIRECTORY)/build/ios.xcarchive/dSYMs/ -IOS_DESTINATION = 'platform=iOS Simulator,name=iPhone 14' +IOS_DESTINATION = 'platform=iOS Simulator,name=iPhone 15' E2E_PROJECT_PLIST_FILE = $(CURRENT_DIRECTORY)/AEPMessaging/Tests/E2EFunctionalTests/E2EFunctionalTestApp/Info.plist @@ -45,7 +45,11 @@ open-app: clean: (rm -rf build) -archive: clean pod-install build +archive: pod-install _archive + +ci-archive: ci-pod-install _archive + +_archive: clean build xcodebuild -create-xcframework \ -framework $(SIMULATOR_ARCHIVE_PATH)$(EXTENSION_NAME).framework -debug-symbols $(SIMULATOR_ARCHIVE_DSYM_PATH)$(EXTENSION_NAME).framework.dSYM \ -framework $(IOS_ARCHIVE_PATH)$(EXTENSION_NAME).framework -debug-symbols $(IOS_ARCHIVE_DSYM_PATH)$(EXTENSION_NAME).framework.dSYM \ @@ -59,11 +63,17 @@ zip: cd build && zip -r -X $(PROJECT_NAME).xcframework.zip $(PROJECT_NAME).xcframework/ swift package compute-checksum build/$(PROJECT_NAME).xcframework.zip -test: clean +unit-test: clean + @echo "######################################################################" + @echo "### Unit Testing" + @echo "######################################################################" + xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme "UnitTests" -destination $(IOS_DESTINATION) -derivedDataPath build/out -resultBundlePath build/$(PROJECT_NAME).xcresult -enableCodeCoverage YES + +functional-test: clean @echo "######################################################################" - @echo "### Testing iOS" + @echo "### Functional Testing" @echo "######################################################################" - xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme $(PROJECT_NAME) -destination $(IOS_DESTINATION) -derivedDataPath build/out -resultBundlePath build/$(PROJECT_NAME).xcresult -enableCodeCoverage YES + xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme "FunctionalTests" -destination $(IOS_DESTINATION) -derivedDataPath build/out -resultBundlePath build/$(PROJECT_NAME).xcresult -enableCodeCoverage YES install-githook: ./tools/git-hooks/setup.sh @@ -94,9 +104,6 @@ test-SPM-integration: test-podspec: (sh ./Script/test-podspec.sh) -functional-test: pod-install - xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme E2EFunctionalTests -destination 'platform=iOS Simulator,name=iPhone 12' -derivedDataPath build/out - # usage - # make set-environment ENV=[environment] set-environment: @@ -105,4 +112,4 @@ set-environment: # used to test update-versions.sh script locally test-versions: - (sh ./Script/update-versions.sh -n Messaging -v 4.0.0 -d "AEPCore 4.0.0, AEPServices 4.0.0, AEPEdge 4.0.0, AEPEdgeIdentity 4.0.0") + (sh ./Script/update-versions.sh -n Messaging -v 5.0.0 -d "AEPCore 5.0.0, AEPServices 5.0.0, AEPEdge 5.0.0, AEPEdgeIdentity 5.0.0") diff --git a/MessagingDemoAppSwiftUI-Info.plist b/MessagingDemoAppSwiftUI-Info.plist new file mode 100644 index 00000000..9d9c3d7b --- /dev/null +++ b/MessagingDemoAppSwiftUI-Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.adobe.MessagingDemoAppSwiftUI + CFBundleURLSchemes + + messagingdemo + + + + + diff --git a/Package.swift b/Package.swift index 8203e30e..44f9c7b0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. /* @@ -17,18 +17,23 @@ import PackageDescription let package = Package( name: "AEPMessaging", - platforms: [.iOS(.v11)], + platforms: [.iOS(.v12)], products: [ .library(name: "AEPMessaging", targets: ["AEPMessaging"]) ], dependencies: [ - .package(url: "https://github.com/adobe/aepsdk-core-ios.git", .upToNextMajor(from: "4.0.0")), - .package(url: "https://github.com/adobe/aepsdk-edge-ios.git", .upToNextMajor(from: "4.0.0")), - .package(url: "https://github.com/adobe/aepsdk-edgeidentity-ios.git", .upToNextMajor(from: "4.0.0")) + .package(url: "https://github.com/adobe/aepsdk-core-ios.git", .upToNextMajor(from: "5.0.0")), + .package(url: "https://github.com/adobe/aepsdk-edge-ios.git", .upToNextMajor(from: "5.0.0")), + .package(url: "https://github.com/adobe/aepsdk-edgeidentity-ios.git", .upToNextMajor(from: "5.0.0")) ], targets: [ .target(name: "AEPMessaging", - dependencies: ["AEPCore", "AEPServices", "AEPEdge", "AEPEdgeIdentity"], - path: "AEPMessaging/Sources") + dependencies: [ + .product(name: "AEPCore", package: "aepsdk-core-ios"), + .product(name: "AEPServices", package: "aepsdk-core-ios"), + .product(name: "AEPEdge", package: "aepsdk-edge-ios"), + .product(name: "AEPEdgeIdentity", package: "aepsdk-edgeidentity-ios") + ], + path: "AEPMessaging/Sources") ] ) diff --git a/Podfile b/Podfile index 89e01325..999c658b 100644 --- a/Podfile +++ b/Podfile @@ -1,5 +1,7 @@ +source 'https://cdn.cocoapods.org/' + # Uncomment the next line to define a global platform for your project -platform :ios, '11.0' +platform :ios, '12.0' # Comment the next line if you don't want to use dynamic frameworks use_frameworks! @@ -22,34 +24,34 @@ def lib_main end def lib_dev - pod 'AEPCore', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPServices', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPRulesEngine', :git => 'https://github.com/adobe/aepsdk-rulesengine-ios.git', :branch => 'dev-4.0.0' + pod 'AEPCore', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'staging' + pod 'AEPServices', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'staging' + pod 'AEPRulesEngine', :git => 'https://github.com/adobe/aepsdk-rulesengine-ios.git', :branch => 'staging' end def app_main - pod 'AEPCore' - pod 'AEPServices' + lib_main pod 'AEPLifecycle' - pod 'AEPRulesEngine' pod 'AEPSignal' - pod 'AEPEdge' + pod 'AEPEdge' pod 'AEPEdgeIdentity' pod 'AEPEdgeConsent' pod 'AEPAssurance' end def app_dev - pod 'AEPCore', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPServices', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPLifecycle', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPSignal', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPRulesEngine', :git => 'https://github.com/adobe/aepsdk-rulesengine-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPEdge', :git => 'https://github.com/adobe/aepsdk-edge-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPEdgeIdentity', :git => 'https://github.com/adobe/aepsdk-edgeidentity-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPEdgeConsent', :git => 'https://github.com/adobe/aepsdk-edgeconsent-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPAnalytics', :git => 'https://github.com/adobe/aepsdk-analytics-ios.git', :branch => 'dev-v4.0.0' - pod 'AEPAssurance', :git => 'https://github.com/adobe/aepsdk-assurance-ios.git', :branch => 'dev-v4.0.0' + lib_dev + pod 'AEPLifecycle', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'staging' + pod 'AEPSignal', :git => 'https://github.com/adobe/aepsdk-core-ios.git', :branch => 'staging' + pod 'AEPEdge', :git => 'https://github.com/adobe/aepsdk-edge-ios.git', :branch => 'staging' + pod 'AEPEdgeIdentity', :git => 'https://github.com/adobe/aepsdk-edgeidentity-ios.git', :branch => 'staging' + pod 'AEPEdgeConsent', :git => 'https://github.com/adobe/aepsdk-edgeconsent-ios.git', :branch => 'staging' + pod 'AEPAnalytics', :git => 'https://github.com/adobe/aepsdk-analytics-ios.git', :branch => 'staging' + pod 'AEPAssurance', :git => 'https://github.com/adobe/aepsdk-assurance-ios.git', :branch => 'staging' +end + +def test_utils + pod 'AEPTestUtils', :git => 'https://github.com/adobe/aepsdk-testutils-ios.git', :tag => '5.0.0' end # ================== @@ -67,22 +69,32 @@ target 'MessagingDemoAppObjC' do app_main end +target 'MessagingDemoAppSwiftUI' do + app_main +end + + target 'UnitTests' do lib_main + test_utils end target 'FunctionalTests' do app_main + test_utils end target 'E2EFunctionalTests' do app_main + test_utils end target 'FunctionalTestApp' do app_main + test_utils end target 'E2EFunctionalTestApp' do app_main + test_utils end diff --git a/Podfile.lock b/Podfile.lock index b7b646e3..12640afd 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,24 +1,27 @@ PODS: - - AEPAssurance (4.0.0): - - AEPCore (>= 4.0.0) - - AEPServices (>= 4.0.0) - - AEPCore (4.0.0): - - AEPRulesEngine (>= 4.0.0) - - AEPServices (>= 4.0.0) - - AEPEdge (4.0.0): - - AEPCore (>= 4.0.0) - - AEPEdgeIdentity (>= 4.0.0) - - AEPEdgeConsent (4.0.0): - - AEPCore (>= 4.0.0) - - AEPEdge (>= 4.0.0) - - AEPEdgeIdentity (4.0.0): - - AEPCore (>= 4.0.0) - - AEPLifecycle (4.0.0): - - AEPCore (>= 4.0.0) - - AEPRulesEngine (4.0.0) - - AEPServices (4.0.0) - - AEPSignal (4.0.0): - - AEPCore (>= 4.0.0) + - AEPAssurance (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPServices (< 6.0.0, >= 5.0.0) + - AEPCore (5.0.0): + - AEPRulesEngine (< 6.0.0, >= 5.0.0) + - AEPServices (< 6.0.0, >= 5.0.0) + - AEPEdge (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPEdgeIdentity (< 6.0.0, >= 5.0.0) + - AEPEdgeConsent (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPEdge (< 6.0.0, >= 5.0.0) + - AEPEdgeIdentity (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPLifecycle (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPRulesEngine (5.0.0) + - AEPServices (5.0.0) + - AEPSignal (5.0.0): + - AEPCore (< 6.0.0, >= 5.0.0) + - AEPTestUtils (5.0.0): + - AEPCore + - AEPServices - SwiftLint (0.52.0) DEPENDENCIES: @@ -31,6 +34,7 @@ DEPENDENCIES: - AEPRulesEngine - AEPServices - AEPSignal + - AEPTestUtils (from `https://github.com/adobe/aepsdk-testutils-ios.git`, tag `5.0.0`) - SwiftLint (= 0.52.0) SPEC REPOS: @@ -46,18 +50,29 @@ SPEC REPOS: - AEPSignal - SwiftLint +EXTERNAL SOURCES: + AEPTestUtils: + :git: https://github.com/adobe/aepsdk-testutils-ios.git + :tag: 5.0.0 + +CHECKOUT OPTIONS: + AEPTestUtils: + :git: https://github.com/adobe/aepsdk-testutils-ios.git + :tag: 5.0.0 + SPEC CHECKSUMS: - AEPAssurance: 4fa3138ddd7308c1f9923570f4d2b0b8526a916f - AEPCore: dd7cd69696c768c610e6adc0307032985a381c7e - AEPEdge: ffea1ada1e81c9cb239aac694efa5c8635b50c1f - AEPEdgeConsent: 54c1b6a30a3d646e3d4bc4bae1713755422b471e - AEPEdgeIdentity: c2396b9119abd6eb530ea11efc58ec019b163bd4 - AEPLifecycle: 59be1b5381d8ec4939ece43516ea7d2de4aaba65 - AEPRulesEngine: 458450a34922823286ead045a0c2bd8c27e224c6 - AEPServices: ca493988df250d84fda050124ff7549bcc43c65f - AEPSignal: b2b332adf4d8a9af6a1b57f5dd8c2e1ea6d5c112 + AEPAssurance: 7f260ded4df38a70a06efebade8c33a3e3221984 + AEPCore: f1c3e9238bb12e7e1103f4407c341ebc65aeab5b + AEPEdge: 6bc7c3f6573fdf0a12fb3ddfd32420112a89c80b + AEPEdgeConsent: d7db1d19eb4c1e2146360ed3c8df315f671b26d5 + AEPEdgeIdentity: 3161ff33434586962946912d6b8e9e8fca1c4d23 + AEPLifecycle: d4e0e1e86d6225d87203875d67f56c48f7ab7f67 + AEPRulesEngine: fe5800653a4bee07b1e41e61b4d5551f0dba557b + AEPServices: e42e5118128e81c0f797fdfb1dc9c4a714d644b8 + AEPSignal: b146a3d4e5af51ff588f4f1ffbd40f1541325143 + AEPTestUtils: 20495b368da57904ca2e9f241d1d8b114f9887b5 SwiftLint: 13280e21cdda6786ad908dc6e416afe5acd1fcb7 -PODFILE CHECKSUM: a2bb645796d52967eeac4dc14f7fcbff6d2cfca0 +PODFILE CHECKSUM: 3d7e4dc0e436ffe270cfe06e63fbe8b0564df0e3 -COCOAPODS: 1.12.1 +COCOAPODS: 1.14.3 diff --git a/README.md b/README.md index 44c79939..08e1e21d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Cocoapods](https://img.shields.io/github/v/release/adobe/aepsdk-messaging-ios?label=CocoaPods&logo=apple&logoColor=white&color=orange&sort=semver)](https://cocoapods.org/pods/AEPMessaging) [![SPM](https://img.shields.io/github/v/release/adobe/aepsdk-messaging-ios?label=SPM&logo=apple&logoColor=white&color=orange&sort=semver)](https://github.com/adobe/aepsdk-messaging-ios/releases) [![CircleCI](https://img.shields.io/circleci/project/github/adobe/aepsdk-messaging-ios/main.svg?logo=circleci&label=Build)](https://circleci.com/gh/adobe/workflows/aepsdk-messaging-ios) -[![Code Coverage](https://img.shields.io/codecov/c/github/adobe/aepsdk-messaging-ios/main.svg?logo=codecov&label=Coverage)](https://codecov.io/gh/adobe/aepsdk-messaging-ios/branch/main) + ## About this project @@ -17,7 +17,7 @@ The AEPMessaging extension enables the following workflows: For further information about Adobe SDKs, visit the [developer documentation](https://developer.adobe.com/client-sdks/documentation/). ## Requirements -- Xcode 14.1 (or newer) +- Xcode 15 (or newer) - Swift 5.1 (or newer) ## Installation diff --git a/Script/test-SPM.sh b/Script/test-SPM.sh index 7dda7e66..6fb84488 100755 --- a/Script/test-SPM.sh +++ b/Script/test-SPM.sh @@ -30,7 +30,7 @@ let package = Package( name: \"TestProject\", defaultLocalization: \"en-US\", platforms: [ - .iOS(.v11) + .iOS(.v12) ], products: [ .library( @@ -64,7 +64,7 @@ let package = Package( swift package update swift package resolve -# This is nececery to avoid internal PIF error +# This is necessary to avoid internal PIF error swift package dump-pif > /dev/null (xcodebuild clean -scheme TestProject -destination 'generic/platform=iOS' > /dev/null) || : diff --git a/Script/test-podspec.sh b/Script/test-podspec.sh index c7edd30c..b9a46444 100755 --- a/Script/test-podspec.sh +++ b/Script/test-podspec.sh @@ -21,11 +21,27 @@ mkdir -p $PROJECT_NAME && cd $PROJECT_NAME # Create a new Xcode project. swift package init -swift package generate-xcodeproj + +# Use Xcodegen to generate the project. +echo " +name: $PROJECT_NAME +options: + bundleIdPrefix: $PROJECT_NAME +targets: + $PROJECT_NAME: + type: framework + sources: Sources + platform: iOS + deploymentTarget: "12.0" + settings: + GENERATE_INFOPLIST_FILE: YES +" >>project.yml + +xcodegen generate # Create a Podfile with our pod as dependency. echo " -platform :ios, '11.0' +platform :ios, '12.0' target '$PROJECT_NAME' do use_frameworks! pod 'AEPMessaging', :path => '../AEPMessaging.podspec' @@ -37,19 +53,19 @@ pod install # Archive for generic iOS device echo '############# Archive for generic iOS device ###############' -xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' +xcodebuild archive -scheme TestProject -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' # Build for generic iOS device echo '############# Build for generic iOS device ###############' -xcodebuild clean build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' +xcodebuild clean build -scheme TestProject -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' # Archive for x86_64 simulator echo '############# Archive for simulator ###############' -xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' +xcodebuild archive -scheme TestProject -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' # Build for x86_64 simulator echo '############# Build for simulator ###############' -xcodebuild clean build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' +xcodebuild clean build -scheme TestProject -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' # Clean up. cd ../ diff --git a/TestApps/MessagingDemoApp/AppDelegate.swift b/TestApps/MessagingDemoApp/AppDelegate.swift index 9fb26ce5..41727204 100644 --- a/TestApps/MessagingDemoApp/AppDelegate.swift +++ b/TestApps/MessagingDemoApp/AppDelegate.swift @@ -49,10 +49,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // configure MobileCore.configureWith(appId: "") // set `messaging.useSandbox` to "true" to test push notifications in debug environment (Apps signed with Development Certificate) - #if DEBUG - let debugConfig = ["messaging.useSandbox": true] - MobileCore.updateConfigurationWith(configDict: debugConfig) - #endif +// #if DEBUG +// let debugConfig = ["messaging.useSandbox": true] +// MobileCore.updateConfigurationWith(configDict: debugConfig) +// #endif } registerForPushNotifications(application) @@ -114,7 +114,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - Messaging.handleNotificationResponse(response) + Messaging.handleNotificationResponse(response) { url in + print("") + return false + } // Always call the completion handler when done. completionHandler() } diff --git a/TestApps/MessagingDemoApp/ViewController.swift b/TestApps/MessagingDemoApp/ViewController.swift index 30cf8f09..edf54ca6 100644 --- a/TestApps/MessagingDemoApp/ViewController.swift +++ b/TestApps/MessagingDemoApp/ViewController.swift @@ -30,11 +30,13 @@ class ViewController: UIViewController { } @IBAction func triggerFullscreen(_: Any) { - MobileCore.track(action: "fullscreen", data: ["testFullscreen": "true"]) + MobileCore.track(action: "once", data: nil) +// MobileCore.track(action: "fullscreen", data: ["testFullscreen": "true"]) } @IBAction func triggerModal(_: Any) { - MobileCore.track(action: "triggerModal", data: ["testModal": "true"]) + MobileCore.track(action: "migrate", data: nil) +// MobileCore.track(action: "triggerModal", data: ["testModal": "true"]) } @IBAction func triggerBannerTop(_: Any) { @@ -71,7 +73,7 @@ class ViewController: UIViewController { // see Assets/nativeMethodCallingSample.html for an example of how to call this method message?.handleJavascriptMessage("buttonClicked") { content in print("magical handling of our content from js! content is: \(content ?? "empty")") - message?.track(content as? String, withEdgeEventType: .inappInteract) + message?.track(content as? String, withEdgeEventType: .interact) } // if using the webview for something, make sure to dispatch back to the main thread @@ -92,12 +94,12 @@ class ViewController: UIViewController { // if we're not showing the message now, we can save it for later if !showMessages { currentMessage = message - currentMessage?.track("message suppressed", withEdgeEventType: .inappTrigger) + currentMessage?.track("message suppressed", withEdgeEventType: .trigger) } else if autoDismiss { currentMessage = message let _ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in timer.invalidate() - self.currentMessage?.track("test for reporting", withEdgeEventType: .inappInteract) + self.currentMessage?.track("test for reporting", withEdgeEventType: .interact) self.currentMessage?.dismiss() } } diff --git a/TestApps/MessagingDemoAppObjC/AppDelegate.m b/TestApps/MessagingDemoAppObjC/AppDelegate.m index 0cf4bb52..612e532f 100644 --- a/TestApps/MessagingDemoAppObjC/AppDelegate.m +++ b/TestApps/MessagingDemoAppObjC/AppDelegate.m @@ -73,7 +73,7 @@ -(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNoti - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { - [AEPMobileMessaging handleNotificationResponse:response closure:^(AEPPushTrackingStatus status){ + [AEPMobileMessaging handleNotificationResponse:response urlHandler:nil closure:^(AEPPushTrackingStatus status){ if (status == AEPPushTrackingStatusTrackingInitiated) { NSLog(@"Successfully started push notification tracking"); } diff --git a/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/Contents.json b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/CodeBasedOffersView.swift b/TestApps/MessagingDemoAppSwiftUI/CodeBasedOffersView.swift new file mode 100644 index 00000000..9580cf48 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/CodeBasedOffersView.swift @@ -0,0 +1,73 @@ +/* +Copyright 2023 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 AEPMessaging +import SwiftUI + +struct CodeBasedOffersView: View { + @State var propositionsDict: [Surface: [Proposition]]? = nil + @State private var viewDidLoad = false + + // prod surfaces +// let testSurface = Surface(path: "codeBasedView#customHtmlOffer") + let testSurface = Surface(path: "sb/cbe-json-object") +// let testSurface = Surface(path: "sb/cbe-json") + + // staging surfaces +// let testSurface = Surface(path: "cbeoffers3") + + var body: some View { + VStack { + Text("Code Based Experiences") + .font(Font.title) + .padding(.top, 30) + List { + if let codePropositions: [Proposition] = propositionsDict?[testSurface], + !codePropositions.isEmpty, + let propItems = codePropositions.first?.items as? [PropositionItem] { + ForEach(propItems, id:\.itemId) { item in + if item.schema == .htmlContent { + CustomHtmlView(htmlString: item.htmlContent ?? "", + trackAction: item.track(_:withEdgeEventType:forTokens:)) + } else if item.schema == .jsonContent { + if let jsonArray = item.jsonContentArray { + CustomTextView(text: jsonArray.description, + trackAction: item.track(_:withEdgeEventType:forTokens:)) + } else { + CustomTextView(text: item.jsonContentDictionary?.description ?? "", + trackAction: item.track(_:withEdgeEventType:forTokens:)) + } + } + } + } + } + } + .onAppear { + if viewDidLoad == false { + viewDidLoad = true + Messaging.updatePropositionsForSurfaces([testSurface]) + } + Messaging.getPropositionsForSurfaces([testSurface]) { propositionsDict, error in + guard error == nil else { + return + } + self.propositionsDict = propositionsDict + } + } + } +} + +struct CodeBasedOffersView_Previews: PreviewProvider { + static var previews: some View { + CodeBasedOffersView() + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/CustomHtmlView.swift b/TestApps/MessagingDemoAppSwiftUI/CustomHtmlView.swift new file mode 100644 index 00000000..df2f4565 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/CustomHtmlView.swift @@ -0,0 +1,50 @@ +/* +Copyright 2023 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 SwiftUI +import WebKit +import AEPMessaging + +struct CustomHtmlView: View { + @State var htmlString: String + var trackAction: ((String?, MessagingEdgeEventType, [String]?) -> Void)? = nil + + var body: some View { + WebView(htmlString: self.$htmlString) + .multilineTextAlignment(.center) + .frame(height: 150) + .frame(maxWidth: .infinity) + .onAppear { + self.trackAction?(nil, .display, nil) + } + .onTapGesture { + self.trackAction?(nil, .interact, nil) + } + } +} + +struct WebView: UIViewRepresentable { + @Binding var htmlString: String + + func makeUIView(context: Context) -> WKWebView { + return WKWebView() + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + uiView.loadHTMLString(self.htmlString, baseURL: nil) + } +} + +struct CustomHtmlView_Previews: PreviewProvider { + static var previews: some View { + CustomHtmlView(htmlString: "

Sample html

") + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/CustomImageView.swift b/TestApps/MessagingDemoAppSwiftUI/CustomImageView.swift new file mode 100644 index 00000000..a7262830 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/CustomImageView.swift @@ -0,0 +1,52 @@ +/* +Copyright 2023 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 SwiftUI + +struct CustomImageView: View { + @StateObject private var imageLoader: ImageLoader + + init(url: String, displayAction: (() -> Void)? = nil, tapAction: (() -> Void)? = nil) { + _imageLoader = StateObject(wrappedValue: ImageLoader(urlString: url)) + } + + var body: some View { + Image(uiImage: imageLoader.uiImage ?? UIImage()) + .resizable() + .aspectRatio(contentMode: .fit) + } +} + +class ImageLoader: ObservableObject { + @Published var uiImage: UIImage? + + init(urlString: String) { + guard let url = URL(string: urlString) else { + return + } + + URLSession.shared.dataTask(with: url) { data, _, _ in + guard let data = data else { return } + + DispatchQueue.main.async { + self.objectWillChange.send() + self.uiImage = UIImage(data: data) + } + }.resume() + } +} + +struct CustomImageView_Previews: PreviewProvider { + static var previews: some View { + CustomImageView(url: "https://gblobscdn.gitbook.com/spaces%2F-Lf1Mc1caFdNCK_mBwhe%2Favatar-1585843848509.png?alt=media") + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/CustomTextView.swift b/TestApps/MessagingDemoAppSwiftUI/CustomTextView.swift new file mode 100644 index 00000000..401bb65e --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/CustomTextView.swift @@ -0,0 +1,37 @@ +/* +Copyright 2023 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 SwiftUI +import AEPMessaging + +struct CustomTextView: View { + @State var text: String + var trackAction: ((String?, MessagingEdgeEventType, [String]?) -> Void)? = nil + + var body: some View { + Text(text) + .multilineTextAlignment(.center) + .frame(height: 150) + .frame(maxWidth: .infinity) + .onAppear { + self.trackAction?(nil, .display, nil) + } + .onTapGesture { + self.trackAction?(nil, .interact, nil) + } + } +} + +struct CustomTextView_Previews: PreviewProvider { + static var previews: some View { + CustomTextView(text: "Sample text.") + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/FeedItemDetailView.swift b/TestApps/MessagingDemoAppSwiftUI/FeedItemDetailView.swift new file mode 100644 index 00000000..61d70734 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/FeedItemDetailView.swift @@ -0,0 +1,63 @@ +/* +Copyright 2023 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 AEPMessaging +import SwiftUI + +struct FeedItemDetailView: View { + @State var feedItem: FeedItem + + var body: some View { + ScrollView { + VStack { + Text(feedItem.title) + .font(.title) + .padding(.top, 30) + CustomImageView(url: feedItem.imageUrl ?? "") + .frame(width: 350, height: 170) + .frame(maxWidth: .infinity) + Text(feedItem.body) + .multilineTextAlignment(.center) + .frame(height: 100) + .frame(maxWidth: .infinity) + Button(action: { + if + let actionUrl = feedItem.actionUrl, + let url = URL(string: actionUrl) { + UIApplication.shared.open(url) + } + }) { + HStack { + Text(feedItem.actionTitle ?? "OK") + .font(.title3) + } + .frame(maxWidth: 150) + .padding() + .background(Color.accentColor) + .foregroundColor(.black) + .clipShape(Capsule()) + } + } + } + } +} + +struct FeedItemDetailView_Previews: PreviewProvider { + static var previews: some View { + FeedItemDetailView(feedItem: FeedItem( + title: "Flash spring sale!", + body: "All hiking gear is now up to 30% off at checkout.", + imageUrl: "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:cd6f726b-ea5a-4308-b1ee-7a8dd1488020/oak:1.0::ci:4363a82474f25c79f2588786cd82e3b2/dd2c2c8e-bd5c-3116-8a7a-e85c2c54549f", + actionUrl: "https://luma.com/springsale", + actionTitle: "Shop the sale!")) + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/FeedItemView.swift b/TestApps/MessagingDemoAppSwiftUI/FeedItemView.swift new file mode 100644 index 00000000..14e776c7 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/FeedItemView.swift @@ -0,0 +1,45 @@ +/* +Copyright 2023 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 AEPMessaging +import SwiftUI + +struct FeedItemView: View { + @State var feedItem: FeedItem + + var body: some View { + HStack(spacing: 10) { + CustomImageView(url: feedItem.imageUrl ?? "") + .frame(width: 100, height: 100) + VStack(alignment: .leading) { + Text(feedItem.title) + .font(.headline) + Text(feedItem.body) + .font(.body) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + } +} + +struct FeedItemView_Previews: PreviewProvider { + static var previews: some View { + FeedItemView(feedItem: FeedItem( + title: "Flash spring sale!", + body: "All hiking gear is now up to 30% off at checkout.", + imageUrl: "https://d14dq8eoa1si34.cloudfront.net/2a6ef2f0-1167-11eb-88c6-b512a5ef09a7/urn:aaid:aem:cd6f726b-ea5a-4308-b1ee-7a8dd1488020/oak:1.0::ci:4363a82474f25c79f2588786cd82e3b2/dd2c2c8e-bd5c-3116-8a7a-e85c2c54549f", + actionUrl: "https://luma.com/springsale", + actionTitle: "Shop the sale!")) + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/FeedsView.swift b/TestApps/MessagingDemoAppSwiftUI/FeedsView.swift new file mode 100644 index 00000000..869f21ce --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/FeedsView.swift @@ -0,0 +1,66 @@ +/* +Copyright 2023 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 AEPMessaging +import SwiftUI + +struct FeedsView: View { + @State var propositionsDict: [Surface: [Proposition]]? = nil + @State private var propositionsHaveBeenFetched = false + @State private var feedName: String = "API feed" + + // staging feeds + private let surface = Surface(path: "feeds/schema_feeditem_with_id_hack") + // private let surface = Surface(path: "feeds/schema_feeditem_sale") + + var body: some View { + NavigationView { + VStack { + Text(feedName) + .font(.title) + .padding(.top, 30) + List { + ForEach(propositionsDict?[surface]?.compactMap { + $0.items.first } ?? [], id: \.itemId ) { propositionItem in + if let feedItemSchema = propositionItem.feedItemSchemaData, let feedItem = feedItemSchema.getFeedItem() { + NavigationLink(destination: FeedItemDetailView(feedItem: feedItem)) { + FeedItemView(feedItem: feedItem) + } + } + } + } + .listStyle(.plain) + .navigationBarTitle(Text("Back"), displayMode: .inline) + .onAppear { + if !propositionsHaveBeenFetched { + Messaging.updatePropositionsForSurfaces([surface]) + propositionsHaveBeenFetched = true + } + + Messaging.getPropositionsForSurfaces([surface]) { propositionsDict, error in + guard error == nil else { + return + } + self.propositionsDict = propositionsDict + } + } + } + .navigationBarHidden(true) + } + } +} + +struct FeedsView_Previews: PreviewProvider { + static var previews: some View { + FeedsView() + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/HomeView.swift b/TestApps/MessagingDemoAppSwiftUI/HomeView.swift new file mode 100644 index 00000000..51064105 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/HomeView.swift @@ -0,0 +1,45 @@ +/* +Copyright 2023 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 AEPMessaging +import SwiftUI + +struct HomeView: View { + @State private var viewDidLoad = false + + var body: some View { + TabView { + InAppView() + .tabItem { + Label("InApp", systemImage: "doc.richtext.fill") + } + PushView() + .tabItem { + Label("Push", systemImage: "paperplane.fill") + } + CodeBasedOffersView() + .tabItem { + Label("Code Experiences", systemImage: "newspaper.fill") + } + FeedsView() + .tabItem { + Label("Feeds", systemImage: "tray.and.arrow.down.fill") + } + } + } +} + +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + HomeView() + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/InAppView.swift b/TestApps/MessagingDemoAppSwiftUI/InAppView.swift new file mode 100644 index 00000000..9e55a33c --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/InAppView.swift @@ -0,0 +1,183 @@ +/* +Copyright 2023 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 AEPMessaging +import AEPServices +import SwiftUI +import WebKit + +struct InAppView: View { + @State private var viewDidLoad = false + @State private var messageHandler = MessageHandler() + @State private var shouldShowMessages = true + var body: some View { + VStack { + VStack { + Text("In-app") + .font(Font.title2.weight(.bold)) + .frame(height: 70) + .padding(.top, 30) + Divider() + } + Grid(alignment: .leading, horizontalSpacing: 70, verticalSpacing: 30) { + GridRow { + Button("fullscreen") { + MobileCore.track(action: "fullscreen_ss", data: ["testFullscreen": "true"]) + } + + Button("modal") { + MobileCore.track(action: "untilClicked", data: ["testModal": "true"]) + } + } + GridRow { + Button("top banner") { + MobileCore.track(action: "triggerBannerTop", data: ["testBannerTop": "true"]) + } + + Button("bottom banner") { + MobileCore.track(action: "surfaceTesting", data: nil) + } + } + } + VStack { + Text("Event Sequencing") + .font(Font.title2.weight(.bold)) + .frame(height: 70) + .padding(.top, 30) + Divider() + } + Grid(alignment: .leading, horizontalSpacing: 70, verticalSpacing: 30) { + GridRow { + Button("event 1") { + let event = Event(name: "Event1", type: "iam.tester", source: "inbound", data: ["firstEvent": "true"], mask: ["firstEvent"]) + MobileCore.dispatch(event: event) + } + } + GridRow { + Button("event 2") { + let event = Event(name: "Event2", type: "iam.tester", source: "inbound", data: ["secondEvent": "true"], mask: ["secondEvent"]) + MobileCore.dispatch(event: event) + } + Button("1 > 2 > 3?") { + let checkSequenceEvent = Event(name: "Check Sequence", type: "iam.tester", source: "inbound", data: ["checkSequence": "true"]) + MobileCore.dispatch(event: checkSequenceEvent) + } + } + GridRow { + Button("event 3") { + let event = Event(name: "Event3", type: "iam.tester", source: "inbound", data: ["thirdEvent": "true"], mask: ["thirdEvent"]) + MobileCore.dispatch(event: event) + } + } + } + Spacer() + .frame(height: 80) + Grid(alignment: .center, horizontalSpacing: 30, verticalSpacing: 30) { + GridRow { + Button("refresh messages") { + Messaging.refreshInAppMessages() + } + Button("show stored messages") { + messageHandler.currentMessage?.show() + } + } + GridRow { + Toggle("Show message when triggered", isOn: $shouldShowMessages) + .onChange(of: shouldShowMessages) { _ in + messageHandler.showMessages.toggle() + } + } + .gridCellColumns(2) + .gridCellUnsizedAxes([.horizontal]) + } + Spacer() + } + .onAppear { + if viewDidLoad == false { + viewDidLoad = true + MobileCore.messagingDelegate = messageHandler + } + } + } +} + +/// Messaging delegate +private class MessageHandler: MessagingDelegate { + var showMessages = true + var currentMessage: Message? + let autoDismiss = false + + func onShow(message: Showable) { + let fullscreenMessage = message as? FullscreenMessage + print("message was shown \(fullscreenMessage?.debugDescription ?? "undefined")") + } + + func onDismiss(message: Showable) { + let fullscreenMessage = message as? FullscreenMessage + print("message was dismissed \(fullscreenMessage?.debugDescription ?? "undefined")") + } + + func shouldShowMessage(message: Showable) -> Bool { + + // access to the whole message from the parent + let fullscreenMessage = message as? FullscreenMessage + let message = fullscreenMessage?.parent + + // in-line handling of javascript calls + // see Assets/nativeMethodCallingSample.html for an example of how to call this method + message?.handleJavascriptMessage("buttonClicked") { content in + print("magical handling of our content from js! content is: \(content ?? "empty")") + message?.track(content as? String, withEdgeEventType: .interact) + } + + // if using the webview for something, make sure to dispatch back to the main thread + DispatchQueue.main.async { + // access the WKWebView containing the message's UI + let messageWebView = message?.view as? WKWebView + // execute JavaScript inside of the message's WKWebView + messageWebView?.evaluateJavaScript("startTimer();") { result, error in + if error != nil { + // handle error + } + if result != nil { + // do something with the result + } + } + } + + // if we're not showing the message now, we can save it for later + if !showMessages { + currentMessage = message + currentMessage?.track("message suppressed", withEdgeEventType: .trigger) + } else if autoDismiss { + currentMessage = message + let _ = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { timer in + timer.invalidate() + self.currentMessage?.track("test for reporting", withEdgeEventType: .interact) + self.currentMessage?.dismiss() + } + } + + return showMessages + } + + func urlLoaded(_ url: URL) { + print("fullscreen message loaded url: \(url)") + } +} + +struct InAppView_Previews: PreviewProvider { + static var previews: some View { + InAppView() + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/MessagingDemoAppSwiftUIApp.swift b/TestApps/MessagingDemoAppSwiftUI/MessagingDemoAppSwiftUIApp.swift new file mode 100644 index 00000000..e14ec114 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/MessagingDemoAppSwiftUIApp.swift @@ -0,0 +1,146 @@ +/* +Copyright 2023 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 AEPAssurance +import AEPCore +import AEPEdge +import AEPEdgeConsent +import AEPEdgeIdentity +import AEPLifecycle +import AEPSignal +import AEPMessaging +import SwiftUI + +final class AppDelegate: NSObject, UIApplicationDelegate { + + private let ENVIRONMENT_FILE_ID = "" + + func application(_ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + MobileCore.setLogLevel(.trace) + + let extensions = [ + AEPEdgeIdentity.Identity.self, + Lifecycle.self, + Signal.self, + Edge.self, + Messaging.self, +// Assurance.self + ] + + MobileCore.registerExtensions(extensions) { + MobileCore.configureWith(appId: self.ENVIRONMENT_FILE_ID) + + // set `messaging.useSandbox` to "true" to test push notifications in debug environment (Apps signed with Development Certificate) + #if DEBUG +// let debugConfig = ["messaging.useSandbox": true, "edge.environment": "int"] +// MobileCore.updateConfigurationWith(configDict: debugConfig) + #endif + } + + self.registerForPushNotifications(application) + return true + } + + // MARK: - Push Notification registration methods + func registerForPushNotifications(_ application : UIApplication) { + let center = UNUserNotificationCenter.current() + // Ask for user permission + center.requestAuthorization(options: [.badge, .sound, .alert]) { [weak self] granted, _ in + guard granted else { return } + + center.delegate = self as? UNUserNotificationCenterDelegate + + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } + + func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } + let token = tokenParts.joined() + print("Device token is - \(token)") + MobileCore.setPushIdentifier(deviceToken) + } + + func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) { + MobileCore.setPushIdentifier(nil) + } + + // MARK: - Handle Push Notification Reception + // Delegate method that tells the app that a remote notification arrived that indicates there is data to be fetched. + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable : Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + // Handle the silent notifications received from AJO in here + print("silent notification received") + completionHandler(.noData) + } + + + // Delegate method to handle a notification that arrived while the app was running in the foreground. + func userNotificationCenter(_: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .sound, .badge]) + } + + // Delegate method is called when a notification is interacted with + func userNotificationCenter(_: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + // Perform the task associated with the action. + switch response.actionIdentifier { + case "ACCEPT_ACTION": + Messaging.handleNotificationResponse(response) + + case "DECLINE_ACTION": + Messaging.handleNotificationResponse(response) + + // Handle other actions… + default: + Messaging.handleNotificationResponse(response) + } + + // Always call the completion handler when done. + completionHandler() + } +} + +@main +struct MessagingDemoAppSwiftUIApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + HomeView() + .onOpenURL{ url in + Assurance.startSession(url: url) + } + } + .onChange(of: scenePhase) { phase in + switch phase { + case .background: + print("Scene phase changed to background.") + MobileCore.lifecyclePause() + case .active: + print("Scene phase changed to active.") + MobileCore.lifecycleStart(additionalContextData: nil) + case .inactive: + print("Scene phase changed to inactive.") + @unknown default: + print("Unknown scene phase.") + } + } + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json b/TestApps/MessagingDemoAppSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/MessagingDemoAppSwiftUI/PushView.swift b/TestApps/MessagingDemoAppSwiftUI/PushView.swift new file mode 100644 index 00000000..18b945ab --- /dev/null +++ b/TestApps/MessagingDemoAppSwiftUI/PushView.swift @@ -0,0 +1,108 @@ +/* +Copyright 2023 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 SwiftUI + +struct PushView: View { + var body: some View { + VStack { + VStack { + Text("Push") + .font(Font.title2.weight(.bold)) + .frame(height: 100) + .padding(.top, 30) + Divider() + } + Grid(alignment: .leading, horizontalSpacing: 70, verticalSpacing: 30) { + GridRow { + Button("ScheduleNotification") { + scheduleNotification() + } + } + GridRow { + Button("ScheduleNotificationWithCustomAction") { + scheduleNotificationWithCustomAction() + } + } + } + Spacer() + } + } +} + +func scheduleNotification() { + let content = UNMutableNotificationContent() + + content.title = "Notification Title" + content.body = "This is example how to create " + + // userInfo is mimicking data that would be provided in the push payload by Adobe Journey Optimizer + content.userInfo = ["_xdm": ["cjm": ["_experience": ["customerJourneyManagement": + ["messageExecution": ["messageExecutionID": "16-Sept-postman", "messageID": "567", + "journeyVersionID": "some-journeyVersionId", "journeyVersionInstanceId": "someJourneyVersionInstanceId"]]]]]] + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + let identifier = "Local Notification" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error \(error.localizedDescription)") + } + } +} + +func scheduleNotificationWithCustomAction() { + let content = UNMutableNotificationContent() + + content.title = "Notification Title" + content.body = "This is example how to create " + content.categoryIdentifier = "MEETING_INVITATION" + // userInfo is mimicking data that would be provided in the push payload by Adobe Journey Optimizer + content.userInfo = ["_xdm": ["cjm": ["_experience": ["customerJourneyManagement": + ["messageExecution": ["messageExecutionID": "16-Sept-postman", "messageID": "567", + "journeyVersionID": "some-journeyVersionId", "journeyVersionInstanceId": "someJourneyVersionInstanceId"]]]]]] + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + let identifier = "Local Notification" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + // Define the custom actions. + let acceptAction = UNNotificationAction(identifier: "ACCEPT_ACTION", + title: "Accept", + options: .foreground) + let declineAction = UNNotificationAction(identifier: "DECLINE_ACTION", + title: "Decline", + options: .destructive) + // Define the notification type + let meetingInviteCategory = + UNNotificationCategory(identifier: "MEETING_INVITATION", + actions: [acceptAction, declineAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + // Register the notification type. + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.setNotificationCategories([meetingInviteCategory]) + + notificationCenter.add(request) { error in + if let error = error { + print("Error \(error.localizedDescription)") + } + } +} + +struct PushView_Previews: PreviewProvider { + static var previews: some View { + PushView() + } +}