diff --git a/package-lock.json b/package-lock.json index ffaa49132..e75c79359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adobe/alloy", - "version": "2.18.0", + "version": "2.19.0-beta.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@adobe/alloy", - "version": "2.18.0", + "version": "2.19.0-beta.0", "license": "Apache-2.0", "dependencies": { "@adobe/reactor-load-script": "^1.1.1", @@ -17,7 +17,7 @@ "uuid": "^3.3.2" }, "devDependencies": { - "@adobe/alloy": "^2.18.0", + "@adobe/alloy": "^2.19.0-beta.0", "@babel/cli": "^7.12.8", "@babel/core": "^7.2.2", "@babel/plugin-proposal-object-rest-spread": "^7.3.2", @@ -84,9 +84,9 @@ } }, "node_modules/@adobe/alloy": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.18.0.tgz", - "integrity": "sha512-zNEG0/TqigwSaCk2JU14Bm28GZxDAsuSGE0RSgrFsCH/+cb0VINICHDvDvazTpoNTen4gvv5SDS1s3cHP2rHtw==", + "version": "2.19.0-beta.0", + "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.19.0-beta.0.tgz", + "integrity": "sha512-OKCXuQLVMUQDcLyKSIt81Y+bpxWTJ2H7P+emT9GfGC7j2U4Uqs+eHTeSJtWfggf9+W5bWIJlkhwrcjrZpcMBrg==", "dev": true, "dependencies": { "@adobe/reactor-load-script": "^1.1.1", @@ -19363,9 +19363,9 @@ }, "dependencies": { "@adobe/alloy": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.18.0.tgz", - "integrity": "sha512-zNEG0/TqigwSaCk2JU14Bm28GZxDAsuSGE0RSgrFsCH/+cb0VINICHDvDvazTpoNTen4gvv5SDS1s3cHP2rHtw==", + "version": "2.19.0-beta.0", + "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.19.0-beta.0.tgz", + "integrity": "sha512-OKCXuQLVMUQDcLyKSIt81Y+bpxWTJ2H7P+emT9GfGC7j2U4Uqs+eHTeSJtWfggf9+W5bWIJlkhwrcjrZpcMBrg==", "dev": true, "requires": { "@adobe/reactor-load-script": "^1.1.1", diff --git a/package.json b/package.json index 48f93ff8b..7a90e4f11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/alloy", - "version": "2.18.0", + "version": "2.19.0-beta.0", "description": "Adobe Experience Platform Web SDK", "main": "libEs5/index.js", "module": "libEs6/index.js", @@ -66,7 +66,7 @@ "uuid": "^3.3.2" }, "devDependencies": { - "@adobe/alloy": "^2.18.0", + "@adobe/alloy": "^2.19.0-beta.0", "@babel/cli": "^7.12.8", "@babel/core": "^7.2.2", "@babel/plugin-proposal-object-rest-spread": "^7.3.2", diff --git a/rollup.test.config.js b/rollup.test.config.js index 79ae3600b..33abab56f 100644 --- a/rollup.test.config.js +++ b/rollup.test.config.js @@ -48,6 +48,7 @@ if (argv.reporters && argv.reporters.split(",").includes("coverage")) { module.exports = { output: { + sourcemap: true, format: "iife", // Allow non-IE browsers and IE11 // document.documentMode was added in IE8, and is specific to IE. diff --git a/src/components/DataCollector/index.js b/src/components/DataCollector/index.js index 78c93c6a2..ecd052e86 100644 --- a/src/components/DataCollector/index.js +++ b/src/components/DataCollector/index.js @@ -89,7 +89,8 @@ const createDataCollector = ({ eventManager, logger }) => { const { renderDecisions = false, responseHeaders = {}, - responseBody = { handle: [] } + responseBody = { handle: [] }, + personalization } = options; const event = eventManager.createEvent(); @@ -97,7 +98,8 @@ const createDataCollector = ({ eventManager, logger }) => { return eventManager.applyResponse(event, { renderDecisions, responseHeaders, - responseBody + responseBody, + personalization }); } } diff --git a/src/components/DataCollector/validateApplyResponse.js b/src/components/DataCollector/validateApplyResponse.js index a7f411836..614dad21e 100644 --- a/src/components/DataCollector/validateApplyResponse.js +++ b/src/components/DataCollector/validateApplyResponse.js @@ -29,7 +29,10 @@ export default ({ options }) => { payload: anything().required() }) ).required() - }).required() + }).required(), + personalization: objectOf({ + sendDisplayNotifications: boolean().default(true) + }).default({ sendDisplayNotifications: true }) }).noUnknownFields(); return validator(options); diff --git a/src/components/DataCollector/validateUserEventOptions.js b/src/components/DataCollector/validateUserEventOptions.js index 9ba6e320c..dbf672052 100644 --- a/src/components/DataCollector/validateUserEventOptions.js +++ b/src/components/DataCollector/validateUserEventOptions.js @@ -30,8 +30,10 @@ export default ({ options }) => { decisionScopes: arrayOf(string()).uniqueItems(), personalization: objectOf({ decisionScopes: arrayOf(string()).uniqueItems(), - surfaces: arrayOf(string()).uniqueItems() - }), + surfaces: arrayOf(string()).uniqueItems(), + sendDisplayNotifications: boolean().default(true), + includePendingDisplayNotifications: boolean().default(false) + }).default({ sendDisplayNotifications: true }), datasetId: string(), mergeId: string(), edgeConfigOverrides: validateConfigOverride diff --git a/src/components/Personalization/createApplyPropositions.js b/src/components/Personalization/createApplyPropositions.js index dfc840a6d..c122e5e23 100644 --- a/src/components/Personalization/createApplyPropositions.js +++ b/src/components/Personalization/createApplyPropositions.js @@ -10,15 +10,18 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import composePersonalizationResultingObject from "./utils/composePersonalizationResultingObject"; -import { isNonEmptyArray, isObject } from "../../utils"; +import { isNonEmptyArray, isObject, defer } from "../../utils"; import { DOM_ACTION, HTML_CONTENT_ITEM } from "./constants/schema"; import PAGE_WIDE_SCOPE from "../../constants/pageWideScope"; -import { EMPTY_PROPOSITIONS } from "./validateApplyPropositionsOptions"; -export const SUPPORTED_SCHEMAS = [DOM_ACTION, HTML_CONTENT_ITEM]; +const SUPPORTED_SCHEMAS = [DOM_ACTION, HTML_CONTENT_ITEM]; -export default ({ executeDecisions }) => { +export default ({ + processPropositions, + createProposition, + pendingDisplayNotifications, + viewCache +}) => { const filterItemsPredicate = item => SUPPORTED_SCHEMAS.indexOf(item.schema) > -1; @@ -71,20 +74,35 @@ export default ({ executeDecisions }) => { .filter(proposition => isNonEmptyArray(proposition.items)); }; - const applyPropositions = ({ propositions, metadata }) => { + return ({ propositions = [], metadata = {}, viewName }) => { + // We need to immediately call concat so that subsequent sendEvent + // calls will wait for applyPropositions to complete before executing. + const displayNotificationsDeferred = defer(); + pendingDisplayNotifications.concat(displayNotificationsDeferred.promise); + const propositionsToExecute = preparePropositions({ propositions, metadata - }); - return executeDecisions(propositionsToExecute).then(() => { - return composePersonalizationResultingObject(propositionsToExecute, true); - }); - }; + }).map(proposition => createProposition(proposition)); + + return Promise.resolve() + .then(() => { + if (viewName) { + return viewCache.getView(viewName); + } + return []; + }) + .then(additionalPropositions => { + const { render, returnedPropositions } = processPropositions([ + ...propositionsToExecute, + ...additionalPropositions + ]); + + render().then(displayNotificationsDeferred.resolve); - return ({ propositions, metadata = {} }) => { - if (isNonEmptyArray(propositions)) { - return applyPropositions({ propositions, metadata }); - } - return Promise.resolve(EMPTY_PROPOSITIONS); + return { + propositions: returnedPropositions + }; + }); }; }; diff --git a/src/components/Personalization/createAutoRenderingHandler.js b/src/components/Personalization/createAutoRenderingHandler.js deleted file mode 100644 index 0e21c0954..000000000 --- a/src/components/Personalization/createAutoRenderingHandler.js +++ /dev/null @@ -1,77 +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 addRenderAttemptedToDecisions from "./utils/addRenderAttemptedToDecisions"; -import isNonEmptyArray from "../../utils/isNonEmptyArray"; - -const getPropositions = ({ viewCache, viewName, pageWideScopeDecisions }) => { - if (!viewName) { - return { pageWideScopeDecisions, viewPropositions: [] }; - } - - return viewCache.getView(viewName).then(viewPropositions => { - return { pageWideScopeDecisions, viewPropositions }; - }); -}; - -export default ({ viewCache, executeDecisions, showContainers, collect }) => { - return ({ viewName, pageWideScopeDecisions, nonAutoRenderableDecisions }) => { - return Promise.resolve(pageWideScopeDecisions) - .then(propositions => - getPropositions({ - viewCache, - viewName, - executeDecisions, - pageWideScopeDecisions: propositions - }) - ) - .then(propositions => { - executeDecisions(propositions.pageWideScopeDecisions).then( - decisionsMeta => { - if (isNonEmptyArray(decisionsMeta)) { - collect({ decisionsMeta }); - } - } - ); - - if (viewName) { - executeDecisions(propositions.viewPropositions).then( - decisionsMeta => { - collect({ decisionsMeta, viewName }); - } - ); - } - - showContainers(); - - return [ - ...propositions.pageWideScopeDecisions, - ...propositions.viewPropositions - ]; - }) - .then(renderablePropositions => { - return { - decisions: [...nonAutoRenderableDecisions], - propositions: [ - ...addRenderAttemptedToDecisions({ - decisions: renderablePropositions, - renderAttempted: true - }), - ...addRenderAttemptedToDecisions({ - decisions: nonAutoRenderableDecisions, - renderAttempted: false - }) - ] - }; - }); - }; -}; diff --git a/src/components/Personalization/createComponent.js b/src/components/Personalization/createComponent.js index 9abd6110c..50f1eaffb 100644 --- a/src/components/Personalization/createComponent.js +++ b/src/components/Personalization/createComponent.js @@ -10,10 +10,11 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { noop, defer } from "../../utils"; +import { noop, flatMap } from "../../utils"; import createPersonalizationDetails from "./createPersonalizationDetails"; import { AUTHORING_ENABLED } from "./constants/loggerMessage"; import validateApplyPropositionsOptions from "./validateApplyPropositionsOptions"; +import { PropositionEventType } from "./constants/propositionEventType"; export default ({ getPageLocation, @@ -26,7 +27,9 @@ export default ({ viewCache, showContainers, applyPropositions, - setTargetMigration + setTargetMigration, + mergeDecisionsMeta, + pendingDisplayNotifications }) => { return { lifecycle: { @@ -51,7 +54,7 @@ export default ({ // If we are in authoring mode we disable personalization mergeQuery(event, { enabled: false }); - return; + return Promise.resolve(); } const personalizationDetails = createPersonalizationDetails({ @@ -60,33 +63,51 @@ export default ({ decisionScopes, personalization, event, - viewCache, + isCacheInitialized: viewCache.isInitialized(), logger }); + const decisionsMetaPromises = []; + if (personalizationDetails.shouldAddPendingDisplayNotifications()) { + decisionsMetaPromises.push(pendingDisplayNotifications.clear()); + } + if (personalizationDetails.shouldFetchData()) { - const decisionsDeferred = defer(); + const cacheUpdate = viewCache.createCacheUpdate( + personalizationDetails.getViewName() + ); + onRequestFailure(() => cacheUpdate.cancel()); - viewCache.storeViews(decisionsDeferred.promise); - onRequestFailure(() => decisionsDeferred.reject()); fetchDataHandler({ - decisionsDeferred, + cacheUpdate, personalizationDetails, event, onResponse }); - return; + } else if (personalizationDetails.shouldUseCachedData()) { + // eslint-disable-next-line consistent-return + decisionsMetaPromises.push( + viewChangeHandler({ + personalizationDetails, + event, + onResponse, + onRequestFailure + }) + ); } - if (personalizationDetails.shouldUseCachedData()) { - // eslint-disable-next-line consistent-return - return viewChangeHandler({ - personalizationDetails, + // This promise.all waits for both the pending display notifications to be resolved + // (i.e. the top of page call to finish rendering) and the view change handler to + // finish rendering anything for this view. + return Promise.all(decisionsMetaPromises).then(decisionsMetas => { + // We only want to call mergeDecisionsMeta once, but we can get the propositions + // from two places: the pending display notifications and the view change handler. + mergeDecisionsMeta( event, - onResponse, - onRequestFailure - }); - } + flatMap(decisionsMetas, dms => dms), + PropositionEventType.DISPLAY + ); + }); }, onClick({ event, clickedElement }) { onClickHandler({ event, clickedElement }); diff --git a/src/components/Personalization/createExecuteDecisions.js b/src/components/Personalization/createExecuteDecisions.js deleted file mode 100644 index fff2ee8f2..000000000 --- a/src/components/Personalization/createExecuteDecisions.js +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2020 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 { assign, flatMap } from "../../utils"; - -const DEFAULT_ACTION_TYPE = "defaultContent"; - -const identity = item => item; - -const getItemMeta = (item, decisionMeta) => - item.characteristics && item.characteristics.trackingLabel - ? assign( - { trackingLabel: item.characteristics.trackingLabel }, - decisionMeta - ) - : decisionMeta; - -const buildActions = decision => { - const decisionMeta = { - id: decision.id, - scope: decision.scope, - scopeDetails: decision.scopeDetails - }; - - return decision.items.map(item => - assign({ type: DEFAULT_ACTION_TYPE }, item.data, { - meta: getItemMeta(item, decisionMeta) - }) - ); -}; - -const processMetas = (logger, actionResults) => { - const results = flatMap(actionResults, identity); - const finalMetas = []; - const set = new Set(); - - results.forEach(item => { - // for click actions we don't return an item - if (!item) { - return; - } - if (item.error) { - logger.warn(item); - return; - } - - const { meta } = item; - - if (set.has(meta.id)) { - return; - } - - set.add(meta.id); - finalMetas.push(meta); - }); - - return finalMetas; -}; - -export default ({ modules, logger, executeActions }) => { - return decisions => { - const actionResultsPromises = decisions.map(decision => { - const actions = buildActions(decision); - - return executeActions(actions, modules, logger); - }); - return Promise.all(actionResultsPromises) - .then(results => processMetas(logger, results)) - .catch(error => { - logger.error(error); - }); - }; -}; diff --git a/src/components/Personalization/createFetchDataHandler.js b/src/components/Personalization/createFetchDataHandler.js index df45aafc6..0c844e9ed 100644 --- a/src/components/Personalization/createFetchDataHandler.js +++ b/src/components/Personalization/createFetchDataHandler.js @@ -1,5 +1,5 @@ /* -Copyright 2020 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 @@ -9,21 +9,87 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import { defer, groupBy } from "../../utils"; + +const DECISIONS_HANDLE = "personalization:decisions"; export default ({ prehidingStyle, - responseHandler, + showContainers, hideContainers, - mergeQuery + mergeQuery, + collect, + processPropositions, + createProposition, + pendingDisplayNotifications }) => { - return ({ decisionsDeferred, personalizationDetails, event, onResponse }) => { + return ({ cacheUpdate, personalizationDetails, event, onResponse }) => { if (personalizationDetails.isRenderDecisions()) { hideContainers(prehidingStyle); } mergeQuery(event, personalizationDetails.createQueryDetails()); - onResponse(({ response }) => - responseHandler({ decisionsDeferred, personalizationDetails, response }) - ); + let handleNotifications; + if (personalizationDetails.isSendDisplayNotifications()) { + handleNotifications = decisionsMeta => { + if (decisionsMeta.length > 0) { + collect({ + decisionsMeta, + viewName: personalizationDetails.getViewName() + }); + } + }; + } else { + const displayNotificationsDeferred = defer(); + pendingDisplayNotifications.concat(displayNotificationsDeferred.promise); + handleNotifications = displayNotificationsDeferred.resolve; + } + + onResponse(({ response }) => { + const handles = response.getPayloadsByType(DECISIONS_HANDLE); + const propositions = handles.map(handle => createProposition(handle)); + const { + page: pagePropositions = [], + view: viewPropositions = [], + proposition: nonRenderedPropositions = [] + } = groupBy(propositions, p => p.getScopeType()); + + const currentViewPropositions = cacheUpdate.update(viewPropositions); + + let render; + let returnedPropositions; + let returnedDecisions; + + if (personalizationDetails.isRenderDecisions()) { + ({ + render, + returnedPropositions, + returnedDecisions + } = processPropositions( + [...pagePropositions, ...currentViewPropositions], + nonRenderedPropositions + )); + render().then(handleNotifications); + + // Render could take a long time especially if one of the renders + // is waiting for html to appear on the page. We show the containers + // immediately, and whatever renders quickly will not have flicker. + showContainers(); + } else { + ({ returnedPropositions, returnedDecisions } = processPropositions( + [], + [ + ...pagePropositions, + ...currentViewPropositions, + ...nonRenderedPropositions + ] + )); + } + + return { + propositions: returnedPropositions, + decisions: returnedDecisions + }; + }); }; }; diff --git a/src/components/Personalization/createNonRenderingHandler.js b/src/components/Personalization/createNonRenderingHandler.js deleted file mode 100644 index 3c9c61fb5..000000000 --- a/src/components/Personalization/createNonRenderingHandler.js +++ /dev/null @@ -1,54 +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 addRenderAttemptedToDecisions from "./utils/addRenderAttemptedToDecisions"; - -const getViewPropositions = ({ viewCache, viewName, propositions }) => { - if (!viewName) { - return propositions; - } - - return viewCache - .getView(viewName) - .then(viewPropositions => [...viewPropositions, ...propositions]); -}; - -const buildFinalResult = ({ propositions }) => { - return { - decisions: propositions, - propositions: addRenderAttemptedToDecisions({ - decisions: propositions, - renderAttempted: false - }) - }; -}; - -export default ({ viewCache }) => { - return ({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }) => { - const propositions = [ - ...redirectDecisions, - ...pageWideScopeDecisions, - ...nonAutoRenderableDecisions - ]; - - return Promise.resolve(propositions) - .then(items => - getViewPropositions({ viewCache, viewName, propositions: items }) - ) - .then(items => buildFinalResult({ propositions: items })); - }; -}; diff --git a/src/components/Personalization/createOnClickHandler.js b/src/components/Personalization/createOnClickHandler.js index 343b33263..f2a0fdb1b 100644 --- a/src/components/Personalization/createOnClickHandler.js +++ b/src/components/Personalization/createOnClickHandler.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { isNonEmptyArray } from "../../utils"; +import { isNonEmptyArray, isNonEmptyString } from "../../utils"; import { INTERACT } from "./constants/eventType"; import { PropositionEventType } from "./constants/propositionEventType"; import PAGE_WIDE_SCOPE from "../../constants/pageWideScope"; @@ -35,10 +35,10 @@ export default ({ const xdm = { eventType: INTERACT }; const scope = decisionsMeta[0].scope; - if (scope !== PAGE_WIDE_SCOPE) { + if (isNonEmptyString(scope) && scope !== PAGE_WIDE_SCOPE) { xdm.web = { webPageDetails: { - viewName: scope + viewName: scope.toLowerCase() } }; } diff --git a/src/components/Personalization/createOnResponseHandler.js b/src/components/Personalization/createOnResponseHandler.js deleted file mode 100644 index 29901c0af..000000000 --- a/src/components/Personalization/createOnResponseHandler.js +++ /dev/null @@ -1,68 +0,0 @@ -/* -Copyright 2020 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 isNonEmptyArray from "../../utils/isNonEmptyArray"; - -const DECISIONS_HANDLE = "personalization:decisions"; - -export default ({ - autoRenderingHandler, - nonRenderingHandler, - groupDecisions, - handleRedirectDecisions, - showContainers -}) => { - return ({ decisionsDeferred, personalizationDetails, response }) => { - const unprocessedDecisions = response.getPayloadsByType(DECISIONS_HANDLE); - const viewName = personalizationDetails.getViewName(); - - // if personalization payload is empty return empty decisions array - if (unprocessedDecisions.length === 0) { - showContainers(); - decisionsDeferred.resolve({}); - return { - decisions: [], - propositions: [] - }; - } - - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(unprocessedDecisions); - - if ( - personalizationDetails.isRenderDecisions() && - isNonEmptyArray(redirectDecisions) - ) { - decisionsDeferred.resolve({}); - return handleRedirectDecisions(redirectDecisions); - } - // save decisions for views in local cache - decisionsDeferred.resolve(viewDecisions); - - if (personalizationDetails.isRenderDecisions()) { - return autoRenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }); - } - return nonRenderingHandler({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }); - }; -}; diff --git a/src/components/Personalization/createPersonalizationDetails.js b/src/components/Personalization/createPersonalizationDetails.js index c62402bff..5188e469b 100644 --- a/src/components/Personalization/createPersonalizationDetails.js +++ b/src/components/Personalization/createPersonalizationDetails.js @@ -43,7 +43,7 @@ export default ({ decisionScopes, personalization, event, - viewCache, + isCacheInitialized, logger }) => { const viewName = event.getViewName(); @@ -51,6 +51,12 @@ export default ({ isRenderDecisions() { return renderDecisions; }, + isSendDisplayNotifications() { + return !!personalization.sendDisplayNotifications; + }, + shouldAddPendingDisplayNotifications() { + return !!personalization.includePendingDisplayNotifications; + }, getViewName() { return viewName; }, @@ -100,7 +106,7 @@ export default ({ }; }, isCacheInitialized() { - return viewCache.isInitialized(); + return isCacheInitialized; }, shouldFetchData() { return ( diff --git a/src/components/Personalization/createRedirectHandler.js b/src/components/Personalization/createRedirectHandler.js deleted file mode 100644 index e4eca0376..000000000 --- a/src/components/Personalization/createRedirectHandler.js +++ /dev/null @@ -1,37 +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 { REDIRECT_EXECUTION_ERROR } from "./constants/loggerMessage"; - -const getRedirectDetails = redirectDecisions => { - const decision = redirectDecisions[0]; - const { items, id, scope, scopeDetails } = decision; - const { content } = items[0].data; - - return { content, decisions: [{ id, scope, scopeDetails }] }; -}; - -export default ({ collect, window, logger, showContainers }) => { - return redirectDecisions => { - const { content, decisions } = getRedirectDetails(redirectDecisions); - const documentMayUnload = true; - - return collect({ decisionsMeta: decisions, documentMayUnload }) - .then(() => { - window.location.replace(content); - }) - .catch(() => { - showContainers(); - logger.warn(REDIRECT_EXECUTION_ERROR); - }); - }; -}; diff --git a/src/components/Personalization/createViewCacheManager.js b/src/components/Personalization/createViewCacheManager.js index 8830c6b48..896726907 100644 --- a/src/components/Personalization/createViewCacheManager.js +++ b/src/components/Personalization/createViewCacheManager.js @@ -10,39 +10,84 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { assign } from "../../utils"; +import { assign, groupBy } from "../../utils"; import defer from "../../utils/defer"; +import { DEFAULT_CONTENT_ITEM } from "./constants/schema"; -export default () => { - let viewStorage; - const viewStorageDeferred = defer(); +export default ({ createProposition }) => { + const viewStorage = {}; + let cacheUpdateCreatedAtLeastOnce = false; + let previousUpdateCacheComplete = Promise.resolve(); - const storeViews = decisionsPromise => { - decisionsPromise - .then(decisions => { - if (viewStorage === undefined) { - viewStorage = {}; - } - assign(viewStorage, decisions); - viewStorageDeferred.resolve(); + const getViewPropositions = (currentViewStorage, viewName) => { + const viewPropositions = currentViewStorage[viewName.toLowerCase()]; + if (viewPropositions && viewPropositions.length > 0) { + return viewPropositions; + } + + const emptyViewProposition = createProposition( + { + scope: viewName, + scopeDetails: { + characteristics: { + scopeType: "view" + } + }, + items: [ + { + schema: DEFAULT_CONTENT_ITEM + } + ] + }, + false + ); + return [emptyViewProposition]; + }; + + // This should be called before making the request to experience edge. + const createCacheUpdate = viewName => { + const updateCacheDeferred = defer(); + + cacheUpdateCreatedAtLeastOnce = true; + previousUpdateCacheComplete = previousUpdateCacheComplete + .then(() => updateCacheDeferred.promise) + .then(newViewStorage => { + assign(viewStorage, newViewStorage); }) - .catch(() => { - if (viewStorage === undefined) { - viewStorage = {}; + .catch(() => {}); + + return { + update(viewPropositions) { + const viewPropositionsWithScope = viewPropositions.filter(proposition => + proposition.getScope() + ); + const newViewStorage = groupBy(viewPropositionsWithScope, proposition => + proposition.getScope().toLowerCase() + ); + updateCacheDeferred.resolve(newViewStorage); + if (viewName) { + return getViewPropositions(newViewStorage, viewName); } - viewStorageDeferred.resolve(); - }); + return []; + }, + cancel() { + updateCacheDeferred.reject(); + } + }; }; const getView = viewName => { - return viewStorageDeferred.promise.then(() => viewStorage[viewName] || []); + return previousUpdateCacheComplete.then(() => + getViewPropositions(viewStorage, viewName) + ); }; const isInitialized = () => { - return !(viewStorage === undefined); + return cacheUpdateCreatedAtLeastOnce; }; + return { - storeViews, + createCacheUpdate, getView, isInitialized }; diff --git a/src/components/Personalization/createViewChangeHandler.js b/src/components/Personalization/createViewChangeHandler.js index 8440abeff..75e25ff4b 100644 --- a/src/components/Personalization/createViewChangeHandler.js +++ b/src/components/Personalization/createViewChangeHandler.js @@ -10,46 +10,34 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import composePersonalizationResultingObject from "./utils/composePersonalizationResultingObject"; -import { isNonEmptyArray } from "../../utils"; -import { PropositionEventType } from "./constants/propositionEventType"; - -export default ({ - mergeDecisionsMeta, - collect, - executeDecisions, - viewCache -}) => { - return ({ personalizationDetails, event, onResponse }) => { +export default ({ processPropositions, viewCache }) => { + return ({ personalizationDetails, onResponse }) => { + let returnedPropositions; + let returnedDecisions; const viewName = personalizationDetails.getViewName(); - return viewCache.getView(viewName).then(viewDecisions => { + onResponse(() => { + return { + propositions: returnedPropositions, + decisions: returnedDecisions + }; + }); + + return viewCache.getView(viewName).then(propositions => { + let render; if (personalizationDetails.isRenderDecisions()) { - return executeDecisions(viewDecisions).then(decisionsMeta => { - // if there are decisions to be rendered we render them and attach the result in experience.decisions.propositions - if (isNonEmptyArray(decisionsMeta)) { - mergeDecisionsMeta( - event, - decisionsMeta, - PropositionEventType.DISPLAY - ); - onResponse(() => { - return composePersonalizationResultingObject(viewDecisions, true); - }); - return; - } - // if there are no decisions in cache for this view, we will send a empty notification - onResponse(() => { - collect({ decisionsMeta: [], viewName }); - return composePersonalizationResultingObject(viewDecisions, true); - }); - }); + ({ + render, + returnedPropositions, + returnedDecisions + } = processPropositions(propositions)); + return render(); } - - onResponse(() => { - return composePersonalizationResultingObject(viewDecisions, false); - }); - return {}; + ({ returnedPropositions, returnedDecisions } = processPropositions( + [], + propositions + )); + return []; }); }; }; diff --git a/src/components/Personalization/dom-actions/action.js b/src/components/Personalization/dom-actions/action.js index 50cc3716d..28667d370 100644 --- a/src/components/Personalization/dom-actions/action.js +++ b/src/components/Personalization/dom-actions/action.js @@ -25,7 +25,6 @@ export { default as setStyles } from "./setStyles"; export { default as setAttributes } from "./setAttributes"; export { default as swapImage } from "./swapImage"; export { default as rearrangeChildren } from "./rearrangeChildren"; -export { default as click } from "./click"; const renderContent = (elements, content, renderFunc) => { const executions = elements.map(element => renderFunc(element, content)); @@ -34,8 +33,8 @@ const renderContent = (elements, content, renderFunc) => { }; export const createAction = renderFunc => { - return settings => { - const { selector, prehidingSelector, content, meta } = settings; + return itemData => { + const { selector, prehidingSelector, content } = itemData; hideElements(prehidingSelector); @@ -45,13 +44,12 @@ export const createAction = renderFunc => { () => { // if everything is OK, show elements showElements(prehidingSelector); - return { meta }; }, error => { // in case of awaiting timing or error, we need to remove the style tag // hence showing the pre-hidden elements showElements(prehidingSelector); - return { meta, error }; + throw error; } ); }; diff --git a/src/components/Personalization/dom-actions/click.js b/src/components/Personalization/dom-actions/createPreprocess.js similarity index 67% rename from src/components/Personalization/dom-actions/click.js rename to src/components/Personalization/dom-actions/createPreprocess.js index 7a5f7332f..8bb87127d 100644 --- a/src/components/Personalization/dom-actions/click.js +++ b/src/components/Personalization/dom-actions/createPreprocess.js @@ -1,5 +1,5 @@ /* -Copyright 2019 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 @@ -9,10 +9,14 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import { assign } from "../../../utils"; -export default (settings, store) => { - const { selector, meta } = settings; - - store({ selector, meta }); - return Promise.resolve(); +export default preprocessors => action => { + if (!action) { + return action; + } + return preprocessors.reduce( + (processed, fn) => assign(processed, fn(processed)), + action + ); }; diff --git a/src/components/Personalization/dom-actions/executeActions.js b/src/components/Personalization/dom-actions/executeActions.js deleted file mode 100644 index 3c7a3a346..000000000 --- a/src/components/Personalization/dom-actions/executeActions.js +++ /dev/null @@ -1,72 +0,0 @@ -/* -Copyright 2019 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 remapHeadOffers from "./remapHeadOffers"; -import { assign } from "../../../utils"; -import remapCustomCodeOffers from "./remapCustomCodeOffers"; - -const logActionError = (logger, action, error) => { - if (logger.enabled) { - const details = JSON.stringify(action); - const { message, stack } = error; - const errorMessage = `Failed to execute action ${details}. ${message} ${ - stack ? `\n ${stack}` : "" - }`; - - logger.error(errorMessage); - } -}; - -const logActionCompleted = (logger, action) => { - if (logger.enabled) { - const details = JSON.stringify(action); - - logger.info(`Action ${details} executed.`); - } -}; - -const executeAction = (logger, modules, type, args) => { - const execute = modules[type]; - - if (!execute) { - const error = new Error(`DOM action "${type}" not found`); - logActionError(logger, args[0], error); - throw error; - } - return execute(...args); -}; - -const PREPROCESSORS = [remapHeadOffers, remapCustomCodeOffers]; - -const preprocess = action => - PREPROCESSORS.reduce( - (processed, fn) => assign(processed, fn(processed)), - action - ); - -export default (actions, modules, logger) => { - const actionPromises = actions.map(action => { - const processedAction = preprocess(action); - const { type } = processedAction; - - return executeAction(logger, modules, type, [processedAction]) - .then(result => { - logActionCompleted(logger, processedAction); - return result; - }) - .catch(error => { - logActionError(logger, processedAction, error); - throw error; - }); - }); - return Promise.all(actionPromises); -}; diff --git a/src/components/Personalization/dom-actions/index.js b/src/components/Personalization/dom-actions/index.js index 763784428..59f99e605 100644 --- a/src/components/Personalization/dom-actions/index.js +++ b/src/components/Personalization/dom-actions/index.js @@ -11,4 +11,3 @@ governing permissions and limitations under the License. */ export { default as initDomActionsModules } from "./initDomActionsModules"; -export { default as executeActions } from "./executeActions"; diff --git a/src/components/Personalization/dom-actions/initDomActionsModules.js b/src/components/Personalization/dom-actions/initDomActionsModules.js index 92710d8ce..7cdf223fb 100644 --- a/src/components/Personalization/dom-actions/initDomActionsModules.js +++ b/src/components/Personalization/dom-actions/initDomActionsModules.js @@ -23,11 +23,10 @@ import { appendHtml, prependHtml, insertHtmlAfter, - insertHtmlBefore, - click + insertHtmlBefore } from "./action"; -export default store => { +export default () => { return { setHtml: createAction(setHtml), customCode: createAction(prependHtml), @@ -43,8 +42,6 @@ export default store => { insertBefore: createAction(insertHtmlBefore), replaceHtml: createAction(replaceHtml), prependHtml: createAction(prependHtml), - appendHtml: createAction(appendHtml), - click: settings => click(settings, store), - defaultContent: settings => Promise.resolve({ meta: settings.meta }) + appendHtml: createAction(appendHtml) }; }; diff --git a/src/components/Personalization/dom-actions/remapHeadOffers.js b/src/components/Personalization/dom-actions/remapHeadOffers.js index 55b2be517..29341cf3e 100644 --- a/src/components/Personalization/dom-actions/remapHeadOffers.js +++ b/src/components/Personalization/dom-actions/remapHeadOffers.js @@ -40,6 +40,10 @@ export default action => { return result; } + if (selector == null) { + return result; + } + const container = selectNodesWithEq(selector); if (!is(container[0], HEAD)) { return result; diff --git a/src/components/Personalization/event.js b/src/components/Personalization/event.js index 196283ed4..93cb0872b 100644 --- a/src/components/Personalization/event.js +++ b/src/components/Personalization/event.js @@ -19,6 +19,11 @@ export const mergeDecisionsMeta = ( eventType, eventLabel = "" ) => { + // Do not send a display notification with no decisions. Even empty view changes + // should include a proposition. + if (decisionsMeta.length === 0) { + return; + } const xdm = { _experience: { decisioning: { diff --git a/src/components/Personalization/groupDecisions.js b/src/components/Personalization/groupDecisions.js deleted file mode 100644 index 73528dbe4..000000000 --- a/src/components/Personalization/groupDecisions.js +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2020 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 { isNonEmptyArray, includes } from "../../utils"; -import isPageWideScope from "./utils/isPageWideScope"; -import { - DOM_ACTION, - REDIRECT_ITEM, - DEFAULT_CONTENT_ITEM, - MEASUREMENT_SCHEMA -} from "./constants/schema"; -import { VIEW_SCOPE_TYPE } from "./constants/scopeType"; - -const splitItems = (items, schemas) => { - const matched = []; - const nonMatched = []; - - items.forEach(item => { - if (includes(schemas, item.schema)) { - matched.push(item); - } else { - nonMatched.push(item); - } - }); - - return [matched, nonMatched]; -}; - -const createDecision = (decision, items) => { - return { - id: decision.id, - scope: decision.scope, - items, - scopeDetails: decision.scopeDetails - }; -}; - -const splitMergedMetricDecisions = decisions => { - const matchedDecisions = decisions.filter(decision => { - const { items = [] } = decision; - return items.some(item => item.schema === MEASUREMENT_SCHEMA); - }); - const unmatchedDecisions = decisions.filter( - decision => !includes(matchedDecisions, decision) - ); - return { matchedDecisions, unmatchedDecisions }; -}; - -const splitDecisions = (decisions, ...schemas) => { - const matchedDecisions = []; - const unmatchedDecisions = []; - - decisions.forEach(decision => { - const { items = [] } = decision; - const [matchedItems, nonMatchedItems] = splitItems(items, schemas); - - if (isNonEmptyArray(matchedItems)) { - matchedDecisions.push(createDecision(decision, matchedItems)); - } - - if (isNonEmptyArray(nonMatchedItems)) { - unmatchedDecisions.push(createDecision(decision, nonMatchedItems)); - } - }); - return { matchedDecisions, unmatchedDecisions }; -}; - -const appendScopeDecision = (scopeDecisions, decision) => { - if (!scopeDecisions[decision.scope]) { - scopeDecisions[decision.scope] = []; - } - scopeDecisions[decision.scope].push(decision); -}; - -const isViewScope = scopeDetails => - scopeDetails.characteristics && - scopeDetails.characteristics.scopeType && - scopeDetails.characteristics.scopeType === VIEW_SCOPE_TYPE; - -const extractDecisionsByScope = decisions => { - const pageWideScopeDecisions = []; - const nonPageWideScopeDecisions = []; - const viewScopeDecisions = {}; - - if (isNonEmptyArray(decisions)) { - decisions.forEach(decision => { - if (isPageWideScope(decision.scope)) { - pageWideScopeDecisions.push(decision); - } else if (isViewScope(decision.scopeDetails)) { - appendScopeDecision(viewScopeDecisions, decision); - } else { - nonPageWideScopeDecisions.push(decision); - } - }); - } - - return { - pageWideScopeDecisions, - nonPageWideScopeDecisions, - viewScopeDecisions - }; -}; - -const groupDecisions = unprocessedDecisions => { - // split redirect decisions - const decisionsGroupedByRedirectItemSchema = splitDecisions( - unprocessedDecisions, - REDIRECT_ITEM - ); - // split merged measurement decisions - const mergedMetricDecisions = splitMergedMetricDecisions( - decisionsGroupedByRedirectItemSchema.unmatchedDecisions - ); - // split renderable decisions - const decisionsGroupedByRenderableSchemas = splitDecisions( - mergedMetricDecisions.unmatchedDecisions, - DOM_ACTION, - DEFAULT_CONTENT_ITEM - ); - // group renderable decisions by scope - const { - pageWideScopeDecisions, - nonPageWideScopeDecisions, - viewScopeDecisions - } = extractDecisionsByScope( - decisionsGroupedByRenderableSchemas.matchedDecisions - ); - - return { - redirectDecisions: decisionsGroupedByRedirectItemSchema.matchedDecisions, - pageWideScopeDecisions, - viewDecisions: viewScopeDecisions, - nonAutoRenderableDecisions: [ - ...mergedMetricDecisions.matchedDecisions, - ...decisionsGroupedByRenderableSchemas.unmatchedDecisions, - ...nonPageWideScopeDecisions - ] - }; -}; -export default groupDecisions; diff --git a/src/components/Personalization/handlers/createProcessDomAction.js b/src/components/Personalization/handlers/createProcessDomAction.js new file mode 100644 index 000000000..dd825a16b --- /dev/null +++ b/src/components/Personalization/handlers/createProcessDomAction.js @@ -0,0 +1,39 @@ +/* +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. +*/ +export default ({ modules, logger, storeClickMetrics }) => item => { + const { type, selector } = item.getData() || {}; + + if (!type) { + logger.warn("Invalid DOM action data: missing type.", item.getData()); + return {}; + } + + if (type === "click") { + if (!selector) { + logger.warn("Invalid DOM action data: missing selector.", item.getData()); + return {}; + } + storeClickMetrics({ selector, meta: item.getMeta() }); + return { setRenderAttempted: true, includeInNotification: false }; + } + + if (!modules[type]) { + logger.warn("Invalid DOM action data: unknown type.", item.getData()); + return {}; + } + + return { + render: () => modules[type](item.getData()), + setRenderAttempted: true, + includeInNotification: true + }; +}; diff --git a/test/unit/specs/components/Personalization/dom-actions/click.spec.js b/src/components/Personalization/handlers/createProcessHtmlContent.js similarity index 53% rename from test/unit/specs/components/Personalization/dom-actions/click.spec.js rename to src/components/Personalization/handlers/createProcessHtmlContent.js index cb8090965..a19fcba2f 100644 --- a/test/unit/specs/components/Personalization/dom-actions/click.spec.js +++ b/src/components/Personalization/handlers/createProcessHtmlContent.js @@ -9,19 +9,23 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { initDomActionsModules } from "../../../../../../src/components/Personalization/dom-actions"; +export default ({ modules, logger }) => item => { + const { type, selector } = item.getData() || {}; -describe("Personalization::actions::click", () => { - it("should set click tracking attribute", () => { - const store = jasmine.createSpy(); - const modules = initDomActionsModules(store); - const { click } = modules; - const selector = "#click"; - const meta = { a: 1 }; - const settings = { selector, meta }; + if (!selector || !type) { + return {}; + } - click(settings, store); + if (!modules[type]) { + logger.warn("Invalid HTML content data", item.getData()); + return {}; + } - expect(store).toHaveBeenCalledWith({ selector, meta }); - }); -}); + return { + render: () => { + modules[type](item.getData()); + }, + setRenderAttempted: true, + includeInNotification: true + }; +}; diff --git a/src/components/Personalization/handlers/createProcessPropositions.js b/src/components/Personalization/handlers/createProcessPropositions.js new file mode 100644 index 000000000..d58b494a6 --- /dev/null +++ b/src/components/Personalization/handlers/createProcessPropositions.js @@ -0,0 +1,202 @@ +/* +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. +*/ + +export default ({ schemaProcessors, logger }) => { + const wrapRenderWithLogging = (render, item) => () => { + return Promise.resolve() + .then(render) + .then(() => { + if (logger.enabled) { + logger.info(`Action ${item.toString()} executed.`); + } + return true; + }) + .catch(error => { + if (logger.enabled) { + const { message, stack } = error; + const errorMessage = `Failed to execute action ${item.toString()}. ${message} ${stack}`; + logger.error(errorMessage); + } + return false; + }); + }; + + const renderItems = (renderers, meta) => + Promise.all(renderers.map(renderer => renderer())).then(successes => { + // as long as at least one renderer succeeds, we want to add the notification + // to the display notifications + if (!successes.includes(true)) { + return undefined; + } + return meta; + }); + + const processItem = item => { + const processor = schemaProcessors[item.getSchema()]; + if (!processor) { + return {}; + } + return processor(item); + }; + + const processItems = ({ + renderers: existingRenderers, + returnedPropositions: existingReturnedPropositions, + returnedDecisions: existingReturnedDecisions, + items, + proposition + }) => { + let renderers = [...existingRenderers]; + let returnedPropositions = [...existingReturnedPropositions]; + let returnedDecisions = [...existingReturnedDecisions]; + let renderedItems = []; + let nonRenderedItems = []; + let itemRenderers = []; + let atLeastOneWithNotification = false; + let render; + let setRenderAttempted; + let includeInNotification; + let onlyRenderThis = false; + let i = 0; + let item; + + while (items.length > i) { + item = items[i]; + ({ + render, + setRenderAttempted, + includeInNotification, + onlyRenderThis + } = processItem(item)); + if (onlyRenderThis) { + returnedPropositions = []; + returnedDecisions = []; + if (setRenderAttempted) { + renderedItems = [item]; + nonRenderedItems = []; + } else { + renderedItems = []; + nonRenderedItems = [item]; + } + renderers = []; + itemRenderers = [render]; + atLeastOneWithNotification = includeInNotification; + break; + } + if (render) { + itemRenderers.push(wrapRenderWithLogging(render, item)); + } + if (includeInNotification) { + atLeastOneWithNotification = true; + } + if (setRenderAttempted) { + renderedItems.push(item); + } else { + nonRenderedItems.push(item); + } + i += 1; + } + if (itemRenderers.length > 0) { + const meta = atLeastOneWithNotification + ? proposition.getNotification() + : undefined; + renderers.push(() => renderItems(itemRenderers, meta)); + } else if (atLeastOneWithNotification) { + renderers.push(() => proposition.getNotification()); + } + if (renderedItems.length > 0) { + proposition.addToReturnValues( + returnedPropositions, + returnedDecisions, + renderedItems, + true + ); + } + if (nonRenderedItems.length > 0) { + proposition.addToReturnValues( + returnedPropositions, + returnedDecisions, + nonRenderedItems, + false + ); + } + + return { + renderers, + returnedPropositions, + returnedDecisions, + onlyRenderThis + }; + }; + + return (renderPropositions, nonRenderPropositions = []) => { + let renderers = []; + let returnedPropositions = []; + let returnedDecisions = []; + let onlyRenderThis; + let i = 0; + let proposition; + let items; + + while (renderPropositions.length > i) { + proposition = renderPropositions[i]; + items = proposition.getItems(); + ({ + renderers, + returnedPropositions, + returnedDecisions, + onlyRenderThis + } = processItems({ + renderers, + returnedPropositions, + returnedDecisions, + items, + proposition + })); + if (onlyRenderThis) { + break; + } + i += 1; + } + + if (onlyRenderThis) { + // if onlyRenderThis is true, that means returnedPropositions and returnedDecisions + // only contains the proposition that triggered only rendering this. We need to + // add the other propositions to the returnedPropositions and returnedDecisions. + renderPropositions.forEach((p, index) => { + if (index !== i) { + p.addToReturnValues( + returnedPropositions, + returnedDecisions, + p.getItems(), + false + ); + } + }); + } + + nonRenderPropositions.forEach(p => { + p.addToReturnValues( + returnedPropositions, + returnedDecisions, + p.getItems(), + false + ); + }); + const render = () => { + return Promise.all(renderers.map(renderer => renderer())).then(metas => + metas.filter(meta => meta) + ); + }; + return { returnedPropositions, returnedDecisions, render }; + }; +}; diff --git a/src/components/Personalization/handlers/createProcessRedirect.js b/src/components/Personalization/handlers/createProcessRedirect.js new file mode 100644 index 000000000..4f03aa5ae --- /dev/null +++ b/src/components/Personalization/handlers/createProcessRedirect.js @@ -0,0 +1,28 @@ +/* +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. +*/ +export default ({ logger, executeRedirect, collect }) => item => { + const { content } = item.getData() || {}; + + if (!content) { + logger.warn("Invalid Redirect data", item.getData()); + return {}; + } + + const render = () => { + return collect({ decisionsMeta: [item.getMeta()] }).then(() => { + executeRedirect(content); + // We've already sent the display notification, so don't return anything + }); + }; + + return { render, setRenderAttempted: true, onlyRenderThis: true }; +}; diff --git a/src/components/Personalization/handlers/injectCreateProposition.js b/src/components/Personalization/handlers/injectCreateProposition.js new file mode 100644 index 000000000..f94219d44 --- /dev/null +++ b/src/components/Personalization/handlers/injectCreateProposition.js @@ -0,0 +1,98 @@ +/* +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 PAGE_WIDE_SCOPE from "../../../constants/pageWideScope"; + +export default ({ preprocess, isPageWideSurface }) => { + const createItem = (item, meta) => { + const { schema, data, characteristics: { trackingLabel } = {} } = item; + + const processedData = preprocess(data); + + if (trackingLabel) { + meta.trackingLabel = trackingLabel; + } + + return { + getSchema() { + return schema; + }, + getData() { + return processedData; + }, + getMeta() { + return meta; + }, + getOriginalItem() { + return item; + }, + toString() { + return JSON.stringify(item); + }, + toJSON() { + return item; + } + }; + }; + + return (payload, visibleInReturnedItems = true) => { + const { id, scope, scopeDetails, items = [] } = payload; + const { characteristics: { scopeType } = {} } = scopeDetails || {}; + + return { + getScope() { + if (!scope) { + return scope; + } + return scope; + }, + getScopeType() { + if (scope === PAGE_WIDE_SCOPE || isPageWideSurface(scope)) { + return "page"; + } + if (scopeType === "view") { + return "view"; + } + return "proposition"; + }, + getItems() { + return items.map(item => createItem(item, { id, scope, scopeDetails })); + }, + getNotification() { + return { id, scope, scopeDetails }; + }, + toJSON() { + return payload; + }, + addToReturnValues( + propositions, + decisions, + includedItems, + renderAttempted + ) { + if (visibleInReturnedItems) { + propositions.push({ + ...payload, + items: includedItems.map(i => i.getOriginalItem()), + renderAttempted + }); + if (!renderAttempted) { + decisions.push({ + ...payload, + items: includedItems.map(i => i.getOriginalItem()) + }); + } + } + } + }; + }; +}; diff --git a/src/components/Personalization/dom-actions/clicks/index.js b/src/components/Personalization/handlers/processDefaultContent.js similarity index 80% rename from src/components/Personalization/dom-actions/clicks/index.js rename to src/components/Personalization/handlers/processDefaultContent.js index a1ebd0b9e..d697ea123 100644 --- a/src/components/Personalization/dom-actions/clicks/index.js +++ b/src/components/Personalization/handlers/processDefaultContent.js @@ -1,5 +1,5 @@ /* -Copyright 2019 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 @@ -9,7 +9,6 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - -import collectClicks from "./collectClicks"; - -export default collectClicks; +export default () => { + return { setRenderAttempted: true, includeInNotification: true }; +}; diff --git a/src/components/Personalization/index.js b/src/components/Personalization/index.js index 14c92f196..2c33063a9 100644 --- a/src/components/Personalization/index.js +++ b/src/components/Personalization/index.js @@ -12,9 +12,8 @@ governing permissions and limitations under the License. import { string, boolean, objectOf } from "../../utils/validation"; import createComponent from "./createComponent"; -import { initDomActionsModules, executeActions } from "./dom-actions"; +import { initDomActionsModules } from "./dom-actions"; import createCollect from "./createCollect"; -import createExecuteDecisions from "./createExecuteDecisions"; import { hideContainers, showContainers } from "./flicker"; import createFetchDataHandler from "./createFetchDataHandler"; import collectClicks from "./dom-actions/clicks/collectClicks"; @@ -23,15 +22,22 @@ import { mergeDecisionsMeta, mergeQuery } from "./event"; import createOnClickHandler from "./createOnClickHandler"; import createViewCacheManager from "./createViewCacheManager"; import createViewChangeHandler from "./createViewChangeHandler"; -import groupDecisions from "./groupDecisions"; -import createOnResponseHandler from "./createOnResponseHandler"; import createClickStorage from "./createClickStorage"; -import createRedirectHandler from "./createRedirectHandler"; -import createAutorenderingHandler from "./createAutoRenderingHandler"; -import createNonRenderingHandler from "./createNonRenderingHandler"; import createApplyPropositions from "./createApplyPropositions"; import createGetPageLocation from "./createGetPageLocation"; import createSetTargetMigration from "./createSetTargetMigration"; +import remapCustomCodeOffers from "./dom-actions/remapCustomCodeOffers"; +import remapHeadOffers from "./dom-actions/remapHeadOffers"; +import createPreprocess from "./dom-actions/createPreprocess"; +import injectCreateProposition from "./handlers/injectCreateProposition"; +import createAsyncArray from "./utils/createAsyncArray"; +import * as schema from "./constants/schema"; +import processDefaultContent from "./handlers/processDefaultContent"; +import { isPageWideSurface } from "./utils/surfaceUtils"; +import createProcessDomAction from "./handlers/createProcessDomAction"; +import createProcessHtmlContent from "./handlers/createProcessHtmlContent"; +import createProcessRedirect from "./handlers/createProcessRedirect"; +import createProcessPropositions from "./handlers/createProcessPropositions"; const createPersonalization = ({ config, logger, eventManager }) => { const { targetMigrationEnabled, prehidingStyle } = config; @@ -43,41 +49,45 @@ const createPersonalization = ({ config, logger, eventManager }) => { storeClickMetrics } = createClickStorage(); const getPageLocation = createGetPageLocation({ window }); - const viewCache = createViewCacheManager(); - const modules = initDomActionsModules(storeClickMetrics); - const executeDecisions = createExecuteDecisions({ - modules, - logger, - executeActions - }); - const handleRedirectDecisions = createRedirectHandler({ - collect, - window, - logger, - showContainers - }); - const autoRenderingHandler = createAutorenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - const applyPropositions = createApplyPropositions({ - executeDecisions + const modules = initDomActionsModules(); + + const preprocess = createPreprocess([remapHeadOffers, remapCustomCodeOffers]); + const createProposition = injectCreateProposition({ + preprocess, + isPageWideSurface }); - const nonRenderingHandler = createNonRenderingHandler({ viewCache }); - const responseHandler = createOnResponseHandler({ - autoRenderingHandler, - nonRenderingHandler, - groupDecisions, - handleRedirectDecisions, - showContainers + const viewCache = createViewCacheManager({ createProposition }); + + const schemaProcessors = { + [schema.DEFAULT_CONTENT_ITEM]: processDefaultContent, + [schema.DOM_ACTION]: createProcessDomAction({ + modules, + logger, + storeClickMetrics + }), + [schema.HTML_CONTENT_ITEM]: createProcessHtmlContent({ modules, logger }), + [schema.REDIRECT_ITEM]: createProcessRedirect({ + logger, + executeRedirect: url => window.location.replace(url), + collect + }) + }; + + const processPropositions = createProcessPropositions({ + schemaProcessors, + logger }); + + const pendingDisplayNotifications = createAsyncArray(); const fetchDataHandler = createFetchDataHandler({ prehidingStyle, - responseHandler, + showContainers, hideContainers, - mergeQuery + mergeQuery, + collect, + processPropositions, + createProposition, + pendingDisplayNotifications }); const onClickHandler = createOnClickHandler({ mergeDecisionsMeta, @@ -86,9 +96,13 @@ const createPersonalization = ({ config, logger, eventManager }) => { getClickMetasBySelector }); const viewChangeHandler = createViewChangeHandler({ - mergeDecisionsMeta, - collect, - executeDecisions, + processPropositions, + viewCache + }); + const applyPropositions = createApplyPropositions({ + processPropositions, + createProposition, + pendingDisplayNotifications, viewCache }); const setTargetMigration = createSetTargetMigration({ @@ -105,7 +119,9 @@ const createPersonalization = ({ config, logger, eventManager }) => { viewCache, showContainers, applyPropositions, - setTargetMigration + setTargetMigration, + mergeDecisionsMeta, + pendingDisplayNotifications }); }; diff --git a/src/components/Personalization/utils/createAsyncArray.js b/src/components/Personalization/utils/createAsyncArray.js new file mode 100644 index 000000000..c736b8853 --- /dev/null +++ b/src/components/Personalization/utils/createAsyncArray.js @@ -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. +*/ +export default () => { + let latest = Promise.resolve([]); + return { + concat(promise) { + latest = latest.then(existingPropositions => { + return promise + .then(newPropositions => { + return existingPropositions.concat(newPropositions); + }) + .catch(() => { + return existingPropositions; + }); + }); + }, + /** + * Clears the saved propositions, waiting until the next propositions are resolved and available. + * + * @returns {Promise} A promise that resolves to the latest propositions. + */ + clear() { + const oldLatest = latest; + latest = Promise.resolve([]); + return oldLatest; + } + }; +}; diff --git a/src/components/Personalization/validateApplyPropositionsOptions.js b/src/components/Personalization/validateApplyPropositionsOptions.js index 96141a922..fee5cdb02 100644 --- a/src/components/Personalization/validateApplyPropositionsOptions.js +++ b/src/components/Personalization/validateApplyPropositionsOptions.js @@ -9,14 +9,15 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { anything, objectOf, arrayOf } from "../../utils/validation"; +import { anything, objectOf, arrayOf, string } from "../../utils/validation"; export const EMPTY_PROPOSITIONS = { propositions: [] }; export default ({ logger, options }) => { const applyPropositionsOptionsValidator = objectOf({ - propositions: arrayOf(objectOf(anything())).nonEmpty(), - metadata: objectOf(anything()) + propositions: arrayOf(objectOf(anything())), + metadata: objectOf(anything()), + viewName: string() }).required(); try { diff --git a/src/core/createEvent.js b/src/core/createEvent.js index 6bf98131e..84f1a50a3 100644 --- a/src/core/createEvent.js +++ b/src/core/createEvent.js @@ -10,7 +10,25 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { isEmptyObject, deepAssign } from "../utils"; +import { + isEmptyObject, + deepAssign, + isNonEmptyArray, + deduplicateArray +} from "../utils"; + +const getXdmPropositions = xdm => { + return xdm && + // eslint-disable-next-line no-underscore-dangle + xdm._experience && + // eslint-disable-next-line no-underscore-dangle + xdm._experience.decisioning && + // eslint-disable-next-line no-underscore-dangle + isNonEmptyArray(xdm._experience.decisioning.propositions) + ? // eslint-disable-next-line no-underscore-dangle + xdm._experience.decisioning.propositions + : []; +}; export default () => { const content = {}; @@ -63,8 +81,23 @@ export default () => { return; } + const newPropositions = deduplicateArray( + [...getXdmPropositions(userXdm), ...getXdmPropositions(content.xdm)], + (a, b) => + a === b || + (a.id && + b.id && + a.id === b.id && + a.scope && + b.scope && + a.scope === b.scope) + ); if (userXdm) { - event.mergeXdm(userXdm); + this.mergeXdm(userXdm); + } + if (newPropositions.length > 0) { + // eslint-disable-next-line no-underscore-dangle + content.xdm._experience.decisioning.propositions = newPropositions; } if (userData) { @@ -115,7 +148,12 @@ export default () => { return shouldSendEvent; }, getViewName() { - if (!userXdm || !userXdm.web || !userXdm.web.webPageDetails) { + if ( + !userXdm || + !userXdm.web || + !userXdm.web.webPageDetails || + !userXdm.web.webPageDetails.viewName + ) { return undefined; } diff --git a/src/core/createEventManager.js b/src/core/createEventManager.js index 2435b7c15..c07f79f57 100644 --- a/src/core/createEventManager.js +++ b/src/core/createEventManager.js @@ -122,7 +122,8 @@ export default ({ const { renderDecisions = false, responseHeaders = {}, - responseBody = { handle: [] } + responseBody = { handle: [] }, + personalization } = options; const payload = createDataCollectionRequestPayload(); @@ -134,7 +135,7 @@ export default ({ event, renderDecisions, decisionScopes: [PAGE_WIDE_SCOPE], - personalization: {}, + personalization, onResponse: onResponseCallbackAggregator.add, onRequestFailure: noop }) diff --git a/src/components/Personalization/utils/composePersonalizationResultingObject.js b/src/utils/deduplicateArray.js similarity index 61% rename from src/components/Personalization/utils/composePersonalizationResultingObject.js rename to src/utils/deduplicateArray.js index ad1a0e98b..05e71ab85 100644 --- a/src/components/Personalization/utils/composePersonalizationResultingObject.js +++ b/src/utils/deduplicateArray.js @@ -9,17 +9,19 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import addRenderAttemptedToDecisions from "./addRenderAttemptedToDecisions"; +const REFERENCE_EQUALITY = (a, b) => a === b; -export default (decisions = [], renderDecisions) => { - const resultingObject = { - propositions: addRenderAttemptedToDecisions({ - decisions, - renderAttempted: renderDecisions - }) - }; - if (!renderDecisions) { - resultingObject.decisions = decisions; +const findIndex = (array, item, isEqual) => { + for (let i = 0; i < array.length; i += 1) { + if (isEqual(array[i], item)) { + return i; + } } - return resultingObject; + return -1; +}; + +export default (array, isEqual = REFERENCE_EQUALITY) => { + return array.filter( + (item, index) => findIndex(array, item, isEqual) === index + ); }; diff --git a/src/utils/index.js b/src/utils/index.js index 927e3a28b..c38851c2f 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -22,6 +22,7 @@ export { default as createLoggingCookieJar } from "./createLoggingCookieJar"; export { default as createTaskQueue } from "./createTaskQueue"; export { default as crc32 } from "./crc32"; export { default as defer } from "./defer"; +export { default as deduplicateArray } from "./deduplicateArray"; export { default as deepAssign } from "./deepAssign"; export { default as endsWith } from "./endsWith"; export { default as find } from "./find"; diff --git a/test/functional/specs/Personalization/C205528.js b/test/functional/specs/Personalization/C205528.js index 4e5beab79..6c4a4736f 100644 --- a/test/functional/specs/Personalization/C205528.js +++ b/test/functional/specs/Personalization/C205528.js @@ -49,6 +49,6 @@ test("Test C205528: A redirect offer should redirect the page to the URL in the } catch (e) { // an exception will be thrown because a redirect will be executed within the Alloy Client Function } finally { - await t.expect(redirectLogger.requests.length).eql(1); + await t.expect(redirectLogger.count(() => true)).eql(1); } }); diff --git a/test/functional/specs/Personalization/C6364798.js b/test/functional/specs/Personalization/C6364798.js index 4611d20ac..47a8f1135 100644 --- a/test/functional/specs/Personalization/C6364798.js +++ b/test/functional/specs/Personalization/C6364798.js @@ -124,24 +124,14 @@ const simulatePageLoad = async alloy => { personalizationPayload, PAGE_WIDE_SCOPE ); - await t - .expect( - // eslint-disable-next-line no-underscore-dangle - notificationRequestBody.events[0].xdm._experience.decisioning.propositions - ) - .eql(pageWideScopeDecisionsMeta); + await t .expect( // eslint-disable-next-line no-underscore-dangle notificationRequestBody.events[0].xdm._experience.decisioning - .propositionEventType.display + .propositions[0] ) - .eql(1); - // notification for view rendered decisions - const viewNotificationRequest = networkLogger.edgeEndpointLogs.requests[2]; - const viewNotificationRequestBody = JSON.parse( - viewNotificationRequest.request.body - ); + .eql(pageWideScopeDecisionsMeta[0]); const productsViewDecisionsMeta = getDecisionsMetaByScope( personalizationPayload, "products" @@ -149,17 +139,26 @@ const simulatePageLoad = async alloy => { await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning - .propositions + notificationRequestBody.events[0].xdm._experience.decisioning + .propositions[1] + ) + .eql(productsViewDecisionsMeta[0]); + await t + .expect( + // eslint-disable-next-line no-underscore-dangle + notificationRequestBody.events[0].xdm._experience.decisioning.propositions + .length ) - .eql(productsViewDecisionsMeta); + .eql(2); + await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning + notificationRequestBody.events[0].xdm._experience.decisioning .propositionEventType.display ) .eql(1); + const allPropositionsWereRendered = resultingObject.propositions.every( proposition => proposition.renderAttempted ); @@ -176,12 +175,12 @@ const simulateViewChange = async (alloy, personalizationPayload) => { xdm: { web: { webPageDetails: { - viewName: "cart" + viewName: "Cart" } } } }); - const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; + const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[2]; const viewChangeRequestBody = JSON.parse(viewChangeRequest.request.body); // assert that no personalization query was attached to the request await t.expect(viewChangeRequestBody.events[0].query).eql(undefined); @@ -230,30 +229,36 @@ const simulateViewChangeForNonExistingView = async alloy => { } }); - const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[4]; + const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; const noViewViewChangeRequestBody = JSON.parse( noViewViewChangeRequest.request.body ); // assert that no personalization query was attached to the request await t.expect(noViewViewChangeRequestBody.events[0].query).eql(undefined); - // assert that a notification call with xdm.web.webPageDetails.viewName and no personalization meta is sent - await flushPromiseChains(); - const noViewNotificationRequest = networkLogger.edgeEndpointLogs.requests[5]; - const noViewNotificationRequestBody = JSON.parse( - noViewNotificationRequest.request.body - ); await t - .expect(noViewNotificationRequestBody.events[0].xdm.eventType) - .eql("decisioning.propositionDisplay"); + .expect( + // eslint-disable-next-line no-underscore-dangle + noViewViewChangeRequestBody.events[0].xdm._experience.decisioning + .propositions + ) + .eql([ + { + scope: "noView", + scopeDetails: { + characteristics: { + scopeType: "view" + } + } + } + ]); await t .expect( - noViewNotificationRequestBody.events[0].xdm.web.webPageDetails.viewName + noViewViewChangeRequestBody.events[0].xdm.web.webPageDetails.viewName ) .eql("noView"); await t - // eslint-disable-next-line no-underscore-dangle - .expect(noViewNotificationRequestBody.events[0].xdm._experience) - .eql(undefined); + .expect(noViewViewChangeRequestBody.events[0].xdm.eventType) + .eql("noviewoffers"); }; const simulateViewRerender = async (alloy, propositions) => { @@ -269,7 +274,7 @@ const simulateViewRerender = async (alloy, propositions) => { .expect(applyPropositionsResult.propositions.length) .eql(propositions.length); // make sure no new network requests are sent - applyPropositions is a client-side only command. - await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(4); + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(3); }; test("Test C6364798: applyPropositions should re-render SPA view without sending view notifications", async () => { diff --git a/test/functional/specs/Personalization/C782718.js b/test/functional/specs/Personalization/C782718.js index 0a9b504cd..272164a5c 100644 --- a/test/functional/specs/Personalization/C782718.js +++ b/test/functional/specs/Personalization/C782718.js @@ -123,24 +123,14 @@ const simulatePageLoad = async alloy => { personalizationPayload, PAGE_WIDE_SCOPE ); - await t - .expect( - // eslint-disable-next-line no-underscore-dangle - notificationRequestBody.events[0].xdm._experience.decisioning.propositions - ) - .eql(pageWideScopeDecisionsMeta); + await t .expect( // eslint-disable-next-line no-underscore-dangle notificationRequestBody.events[0].xdm._experience.decisioning - .propositionEventType.display + .propositions[0] ) - .eql(1); - // notification for view rendered decisions - const viewNotificationRequest = networkLogger.edgeEndpointLogs.requests[2]; - const viewNotificationRequestBody = JSON.parse( - viewNotificationRequest.request.body - ); + .eql(pageWideScopeDecisionsMeta[0]); const productsViewDecisionsMeta = getDecisionsMetaByScope( personalizationPayload, "products" @@ -148,14 +138,14 @@ const simulatePageLoad = async alloy => { await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning - .propositions + notificationRequestBody.events[0].xdm._experience.decisioning + .propositions[1] ) - .eql(productsViewDecisionsMeta); + .eql(productsViewDecisionsMeta[0]); await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning + notificationRequestBody.events[0].xdm._experience.decisioning .propositionEventType.display ) .eql(1); @@ -180,7 +170,7 @@ const simulateViewChange = async (alloy, personalizationPayload) => { } } }); - const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; + const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[2]; const viewChangeRequestBody = JSON.parse(viewChangeRequest.request.body); // assert that no personalization query was attached to the request await t.expect(viewChangeRequestBody.events[0].query).eql(undefined); @@ -227,30 +217,40 @@ const simulateViewChangeForNonExistingView = async alloy => { } }); - const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[4]; + const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; const noViewViewChangeRequestBody = JSON.parse( noViewViewChangeRequest.request.body ); // assert that no personalization query was attached to the request await t.expect(noViewViewChangeRequestBody.events[0].query).eql(undefined); // assert that a notification call with xdm.web.webPageDetails.viewName and no personalization meta is sent - await flushPromiseChains(); - const noViewNotificationRequest = networkLogger.edgeEndpointLogs.requests[5]; - const noViewNotificationRequestBody = JSON.parse( - noViewNotificationRequest.request.body - ); + await t - .expect(noViewNotificationRequestBody.events[0].xdm.eventType) - .eql("decisioning.propositionDisplay"); + .expect(noViewViewChangeRequestBody.events[0].xdm.eventType) + .eql("noviewoffers"); await t .expect( - noViewNotificationRequestBody.events[0].xdm.web.webPageDetails.viewName + noViewViewChangeRequestBody.events[0].xdm.web.webPageDetails.viewName ) .eql("noView"); await t // eslint-disable-next-line no-underscore-dangle - .expect(noViewNotificationRequestBody.events[0].xdm._experience) - .eql(undefined); + .expect(noViewViewChangeRequestBody.events[0].xdm._experience.decisioning) + .eql({ + propositions: [ + { + scope: "noView", + scopeDetails: { + characteristics: { + scopeType: "view" + } + } + } + ], + propositionEventType: { + display: 1 + } + }); }; test("Test C782718: SPA support with auto-rendering and view notifications", async () => { diff --git a/test/functional/specs/Privacy/IAB/C224677.js b/test/functional/specs/Privacy/IAB/C224677.js index 151c23b3e..9fb9c89b7 100644 --- a/test/functional/specs/Privacy/IAB/C224677.js +++ b/test/functional/specs/Privacy/IAB/C224677.js @@ -11,7 +11,6 @@ governing permissions and limitations under the License. */ import { t } from "testcafe"; import createNetworkLogger from "../../../helpers/networkLogger"; -import { responseStatus } from "../../../helpers/assertions/index"; import createFixture from "../../../helpers/createFixture"; import createResponse from "../../../helpers/createResponse"; import getResponseBody from "../../../helpers/networkLogger/getResponseBody"; @@ -82,5 +81,12 @@ test("Test C224677: Call setConsent when purpose 10 is FALSE", async () => { .notContains("https://ns.adobe.com/aep/errors/EXEG-0301-200"); await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(1); - await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + await t + .expect( + networkLogger.edgeEndpointLogs.count( + ({ response: { statusCode } }) => + statusCode === 200 || statusCode === 207 + ) + ) + .eql(1); }); diff --git a/test/unit/specs/components/Personalization/createApplyPropositions.spec.js b/test/unit/specs/components/Personalization/createApplyPropositions.spec.js index 0f75b7f9e..db4f9a110 100644 --- a/test/unit/specs/components/Personalization/createApplyPropositions.spec.js +++ b/test/unit/specs/components/Personalization/createApplyPropositions.spec.js @@ -10,13 +10,13 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import "jasmine-expect"; import { MIXED_PROPOSITIONS, PAGE_WIDE_SCOPE_DECISIONS } from "./responsesMock/eventResponses"; import createApplyPropositions from "../../../../../src/components/Personalization/createApplyPropositions"; import clone from "../../../../../src/utils/clone"; +import injectCreateProposition from "../../../../../src/components/Personalization/handlers/injectCreateProposition"; const METADATA = { home: { @@ -26,34 +26,52 @@ const METADATA = { }; describe("Personalization::createApplyPropositions", () => { - let executeDecisions; + let processPropositions; + let createProposition; + let pendingDisplayNotifications; + let viewCache; + let applyPropositions; + let render; beforeEach(() => { - executeDecisions = jasmine.createSpy("executeDecisions"); - }); - - it("it should return an empty propositions promise if propositions is empty array", () => { - executeDecisions.and.returnValue( - Promise.resolve(PAGE_WIDE_SCOPE_DECISIONS) + processPropositions = jasmine.createSpy("processPropositions"); + processPropositions.and.callFake(propositions => { + const returnedPropositions = propositions.map(proposition => ({ + ...proposition.toJSON(), + renderAttempted: true + })); + return { returnedPropositions, render }; + }); + render = jasmine.createSpy("render"); + render.and.callFake(() => Promise.resolve("notifications")); + createProposition = injectCreateProposition({ + preprocess: data => data, + isPageWideSurface: () => false + }); + + pendingDisplayNotifications = jasmine.createSpyObj( + "pendingDisplayNotifications", + ["concat"] ); - - const applyPropositions = createApplyPropositions({ - executeDecisions + viewCache = jasmine.createSpyObj("viewCache", ["getView"]); + viewCache.getView.and.returnValue(Promise.resolve([])); + applyPropositions = createApplyPropositions({ + processPropositions, + createProposition, + pendingDisplayNotifications, + viewCache }); + }); - return applyPropositions({ + it("it should return an empty propositions promise if propositions is empty array", async () => { + const result = await applyPropositions({ propositions: [] - }).then(result => { - expect(result).toEqual({ propositions: [] }); - expect(executeDecisions).toHaveBeenCalledTimes(0); }); + expect(result).toEqual({ propositions: [] }); + expect(processPropositions).toHaveBeenCalledOnceWith([]); }); - it("it should apply user-provided dom-action schema propositions", () => { - executeDecisions.and.returnValue( - Promise.resolve(PAGE_WIDE_SCOPE_DECISIONS) - ); - + it("it should apply user-provided dom-action schema propositions", async () => { const expectedExecuteDecisionsPropositions = clone( PAGE_WIDE_SCOPE_DECISIONS ).map(proposition => { @@ -61,60 +79,45 @@ describe("Personalization::createApplyPropositions", () => { return proposition; }); - const applyPropositions = createApplyPropositions({ - executeDecisions - }); - - return applyPropositions({ + const result = await applyPropositions({ propositions: PAGE_WIDE_SCOPE_DECISIONS - }).then(result => { - expect(executeDecisions).toHaveBeenCalledTimes(1); - expect(executeDecisions.calls.first().args[0]).toEqual( - expectedExecuteDecisionsPropositions - ); - - const expectedScopes = expectedExecuteDecisionsPropositions.map( - proposition => proposition.scope - ); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - expect(expectedScopes).toContain(proposition.scope); - expect(proposition.items).toBeArrayOfObjects(); - expect(proposition.items.length).toEqual(2); - }); }); - }); - it("it should merge metadata with propositions that have html-content-item schema", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); + expect(processPropositions).toHaveBeenCalledTimes(1); - const applyPropositions = createApplyPropositions({ - executeDecisions + const expectedScopes = expectedExecuteDecisionsPropositions.map( + proposition => proposition.scope + ); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); + expect(expectedScopes).toContain(proposition.scope); + expect(proposition.items).toBeArrayOfObjects(); + expect(proposition.items.length).toEqual(2); }); + }); - return applyPropositions({ + it("it should merge metadata with propositions that have html-content-item schema", async () => { + const { propositions } = await applyPropositions({ propositions: MIXED_PROPOSITIONS, metadata: METADATA - }).then(() => { - const executedPropositions = executeDecisions.calls.first().args[0]; - expect(executedPropositions.length).toEqual(3); - executedPropositions.forEach(proposition => { - expect(proposition.items.length).toEqual(1); - if (proposition.items[0].id === "442358") { - expect(proposition.scope).toEqual("home"); - expect(proposition.items[0].data.selector).toEqual("#root"); - expect(proposition.items[0].data.type).toEqual("click"); - } else if (proposition.items[0].id === "442359") { - expect(proposition.scope).toEqual("home"); - expect(proposition.items[0].data.selector).toEqual("#home-item1"); - expect(proposition.items[0].data.type).toEqual("setHtml"); - } - }); - expect(executeDecisions).toHaveBeenCalledTimes(1); }); + + expect(propositions.length).toEqual(4); + propositions.forEach(proposition => { + expect(proposition.items.length).toEqual(1); + if (proposition.items[0].id === "442358") { + expect(proposition.items[0].data.selector).toEqual("#root"); + expect(proposition.items[0].data.type).toEqual("click"); + } else if (proposition.items[0].id === "442359") { + expect(proposition.scope).toEqual("home"); + expect(proposition.items[0].data.selector).toEqual("#home-item1"); + expect(proposition.items[0].data.type).toEqual("setHtml"); + } + }); + expect(processPropositions).toHaveBeenCalledTimes(1); }); - it("it should drop items with html-content-item schema when there is no metadata", () => { + it("it should drop items with html-content-item schema when there is no metadata", async () => { const propositions = [ { id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", @@ -142,108 +145,97 @@ describe("Personalization::createApplyPropositions", () => { } ]; - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - - const applyPropositions = createApplyPropositions({ - executeDecisions - }); - - return applyPropositions({ + const result = await applyPropositions({ propositions - }).then(result => { - expect(result.propositions.length).toEqual(1); - expect(result.propositions[0].items.length).toEqual(1); - expect(result.propositions[0].items[0].id).toEqual("442358"); - expect(result.propositions[0].renderAttempted).toBeTrue(); }); - }); - - it("it should return renderAttempted = true on resulting propositions", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - const applyPropositions = createApplyPropositions({ - executeDecisions - }); + expect(result.propositions.length).toEqual(1); + expect(result.propositions[0].items.length).toEqual(1); + expect(result.propositions[0].items[0].id).toEqual("442358"); + expect(result.propositions[0].renderAttempted).toBeTrue(); + }); - return applyPropositions({ + it("it should return renderAttempted = true on resulting propositions", async () => { + const result = await applyPropositions({ propositions: MIXED_PROPOSITIONS - }).then(result => { - expect(result.propositions.length).toEqual(2); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - }); + }); + expect(result.propositions.length).toEqual(3); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); }); }); - it("it should ignore propositions with __view__ scope that have already been rendered", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); + it("it should ignore propositions with __view__ scope that have already been rendered", async () => { + const propositions = JSON.parse(JSON.stringify(MIXED_PROPOSITIONS)); + propositions[4].renderAttempted = true; - const applyPropositions = createApplyPropositions({ - executeDecisions + const result = await applyPropositions({ + propositions }); - - return applyPropositions({ - propositions: MIXED_PROPOSITIONS - }).then(result => { - expect(result.propositions.length).toEqual(2); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - if (proposition.scope === "__view__") { - expect(proposition.items[0].id).not.toEqual("442358"); - } else { - expect(proposition.scope).toEqual("home"); - } - }); + expect(result.propositions.length).toEqual(2); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); + if (proposition.scope === "__view__") { + expect(proposition.items[0].id).not.toEqual("442358"); + } else { + expect(proposition.scope).toEqual("home"); + } }); }); - it("it should ignore items with unsupported schemas", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - + it("it should ignore items with unsupported schemas", async () => { const expectedItemIds = ["442358", "442359"]; - const applyPropositions = createApplyPropositions({ - executeDecisions - }); - - return applyPropositions({ + const { propositions } = await applyPropositions({ propositions: MIXED_PROPOSITIONS - }).then(() => { - const executedPropositions = executeDecisions.calls.first().args[0]; - expect(executedPropositions.length).toEqual(2); - executedPropositions.forEach(proposition => { - expect(proposition.items.length).toEqual(1); - proposition.items.forEach(item => { - expect(expectedItemIds.indexOf(item.id) > -1); - }); + }); + expect(propositions.length).toEqual(3); + propositions.forEach(proposition => { + expect(proposition.items.length).toEqual(1); + proposition.items.forEach(item => { + expect(expectedItemIds.indexOf(item.id) > -1); }); }); }); - it("it should not mutate original propositions", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - - const applyPropositions = createApplyPropositions({ - executeDecisions - }); - + it("it should not mutate original propositions", async () => { const originalPropositions = clone(MIXED_PROPOSITIONS); - return applyPropositions({ + const result = await applyPropositions({ propositions: originalPropositions, metadata: METADATA - }).then(result => { - let numReturnedPropositions = 0; - expect(originalPropositions).toEqual(MIXED_PROPOSITIONS); - result.propositions.forEach(proposition => { - const [original] = originalPropositions.filter( - originalProposition => originalProposition.id === proposition.id - ); - if (original) { - numReturnedPropositions += 1; - expect(proposition).not.toBe(original); + }); + + let numReturnedPropositions = 0; + expect(originalPropositions).toEqual(MIXED_PROPOSITIONS); + result.propositions.forEach(proposition => { + const [original] = originalPropositions.filter( + originalProposition => originalProposition.id === proposition.id + ); + if (original) { + numReturnedPropositions += 1; + expect(proposition).not.toBe(original); + } + }); + expect(numReturnedPropositions).toEqual(4); + }); + + it("concats viewName propositions", async () => { + viewCache.getView.and.returnValue( + Promise.resolve([ + createProposition({ id: "myViewNameProp1", items: [{}] }) + ]) + ); + const result = await applyPropositions({ + viewName: "myViewName" + }); + expect(result).toEqual({ + propositions: [ + { + id: "myViewNameProp1", + items: [{}], + renderAttempted: true } - }); - expect(numReturnedPropositions).toEqual(3); + ] }); }); }); diff --git a/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js b/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js deleted file mode 100644 index e6756d060..000000000 --- a/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js +++ /dev/null @@ -1,239 +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 { - CART_VIEW_DECISIONS, - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS, - SCOPES_FOO1_FOO2_DECISIONS -} from "./responsesMock/eventResponses"; -import createAutoRenderingHandler from "../../../../../src/components/Personalization/createAutoRenderingHandler"; -import isPageWideScope from "../../../../../src/components/Personalization/utils/isPageWideScope"; - -describe("Personalization::createAutoRenderingHandler", () => { - let viewCache; - let executeDecisions; - let showContainers; - let collect; - let pageWideScopeDecisions; - let nonAutoRenderableDecisions; - - beforeEach(() => { - showContainers = jasmine.createSpy("showContainers"); - viewCache = jasmine.createSpyObj("viewCache", ["getView"]); - collect = jasmine.createSpy("collect"); - executeDecisions = jasmine.createSpy("executeDecisions"); - pageWideScopeDecisions = PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS; - nonAutoRenderableDecisions = SCOPES_FOO1_FOO2_DECISIONS; - }); - - it("it should fetch decisions from cache when viewName is present", () => { - const viewName = "cart"; - const executeViewDecisionsPromise = { - then: callback => callback(CART_VIEW_DECISIONS) - }; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - - viewCache.getView.and.returnValue(Promise.resolve(CART_VIEW_DECISIONS)); - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - executeDecisions.and.returnValues( - executePageWideScopeDecisionsPromise, - executeViewDecisionsPromise - ); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).toHaveBeenCalledWith("cart"); - expect(executeDecisions).toHaveBeenCalledTimes(2); - expect(executeDecisions.calls.all()[0].args[0]).toEqual( - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - ); - expect(executeDecisions.calls.all()[1].args[0]).toEqual( - CART_VIEW_DECISIONS - ); - - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - - expect(collect).toHaveBeenCalledTimes(2); - expect(collect.calls.all()[0].args[0]).toEqual({ - decisionsMeta: PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - }); - expect(collect.calls.all()[1].args[0]).toEqual({ - decisionsMeta: CART_VIEW_DECISIONS, - viewName - }); - expect(showContainers).toHaveBeenCalled(); - }); - }); - - it("it should execute page wide scope decisions when no viewName", () => { - const viewName = undefined; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - executeDecisions.and.returnValue(executePageWideScopeDecisionsPromise); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - collect, - showContainers - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if (isPageWideScope(proposition.scope)) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - expect(viewCache.getView).not.toHaveBeenCalled(); - expect(executeDecisions).toHaveBeenCalledTimes(1); - expect(executeDecisions).toHaveBeenCalledWith(pageWideScopeDecisions); - expect(collect).toHaveBeenCalledTimes(1); - expect(collect).toHaveBeenCalledWith({ - decisionsMeta: pageWideScopeDecisions - }); - expect(showContainers).toHaveBeenCalled(); - }); - }); - - it("it calls collect if cache returns empty array for a view", () => { - const viewName = "cart"; - const promise = { - then: callback => callback([]) - }; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - const executeViewDecisionsPromise = { - then: callback => callback([]) - }; - viewCache.getView.and.returnValue(promise); - executeDecisions.and.returnValues( - executePageWideScopeDecisionsPromise, - executeViewDecisionsPromise - ); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).toHaveBeenCalledWith("cart"); - expect(executeDecisions).toHaveBeenCalledWith(pageWideScopeDecisions); - expect(showContainers).toHaveBeenCalled(); - expect(collect).toHaveBeenCalledTimes(2); - expect(collect.calls.all()[1].args[0]).toEqual({ - decisionsMeta: [], - viewName - }); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - }); - }); - - it("it shouldn't call collect when no pageWideScopeDecisions and no viewName", () => { - const viewName = undefined; - - const executePageWideScopeDecisionsPromise = { - then: callback => callback([]) - }; - - executeDecisions.and.returnValue(executePageWideScopeDecisionsPromise); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).not.toHaveBeenCalled(); - expect(showContainers).toHaveBeenCalled(); - expect(collect).not.toHaveBeenCalled(); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - }); - }); -}); diff --git a/test/unit/specs/components/Personalization/createComponent.spec.js b/test/unit/specs/components/Personalization/createComponent.spec.js index fcb8d92d3..be81d2f1a 100644 --- a/test/unit/specs/components/Personalization/createComponent.spec.js +++ b/test/unit/specs/components/Personalization/createComponent.spec.js @@ -24,6 +24,9 @@ describe("Personalization", () => { let event; let personalizationComponent; let setTargetMigration; + let mergeDecisionsMeta; + let pendingDisplayNotifications; + let cacheUpdate; const build = () => { personalizationComponent = createComponent({ @@ -35,7 +38,9 @@ describe("Personalization", () => { mergeQuery, viewCache, showContainers, - setTargetMigration + setTargetMigration, + mergeDecisionsMeta, + pendingDisplayNotifications }); }; @@ -56,9 +61,16 @@ describe("Personalization", () => { mergeQuery = jasmine.createSpy("mergeQuery"); viewCache = jasmine.createSpyObj("viewCache", [ "isInitialized", - "storeViews" + "createCacheUpdate" ]); + cacheUpdate = jasmine.createSpyObj("cacheUpdate", ["update", "cancel"]); + viewCache.createCacheUpdate.and.returnValue(cacheUpdate); setTargetMigration = jasmine.createSpy("setTargetMigration"); + mergeDecisionsMeta = jasmine.createSpy("mergeDecisionsMeta"); + pendingDisplayNotifications = jasmine.createSpyObj( + "pendingDisplayNotifications", + ["clear"] + ); build(); }); @@ -85,7 +97,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); expect(showContainers).not.toHaveBeenCalled(); - expect(viewCache.storeViews).not.toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).not.toHaveBeenCalled(); }); it("should trigger pageLoad if there are decisionScopes", () => { @@ -104,7 +116,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).toHaveBeenCalled(); }); it("should trigger pageLoad if cache is not initialized", () => { const renderDecisions = false; @@ -124,7 +136,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).toHaveBeenCalled(); }); it("should trigger viewHandler if cache is initialized and viewName is provided", () => { const renderDecisions = false; @@ -145,7 +157,7 @@ describe("Personalization", () => { expect(viewChangeHandler).toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).not.toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).not.toHaveBeenCalled(); }); it("should trigger onClickHandler at onClick", () => { personalizationComponent.lifecycle.onClick({ event }); diff --git a/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js b/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js deleted file mode 100644 index 31759f179..000000000 --- a/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js +++ /dev/null @@ -1,127 +0,0 @@ -/* -Copyright 2020 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 createExecuteDecisions from "../../../../../src/components/Personalization/createExecuteDecisions"; - -describe("Personalization::createExecuteDecisions", () => { - let logger; - let executeActions; - let collect; - - const decisions = [ - { - id: 1, - scope: "foo", - scopeDetails: { - test: "blah1" - }, - items: [ - { - schema: "https://ns.adobe.com/personalization/dom-action", - data: { - type: "setHtml", - selector: "#foo", - content: "
Hola Mundo
" - } - } - ] - }, - { - id: 5, - scope: "__view__", - scopeDetails: { - test: "blah2" - }, - items: [ - { - schema: "https://ns.adobe.com/personalization/dom-action", - data: { - type: "setHtml", - selector: "#foo2", - content: "
offer 2
" - } - } - ] - } - ]; - const expectedAction = [ - { - type: "setHtml", - selector: "#foo", - content: "
Hola Mundo
", - meta: { - id: decisions[0].id, - scope: "foo", - scopeDetails: { - test: "blah1" - } - } - } - ]; - const metas = [ - { - id: decisions[0].id, - scope: decisions[0].scope, - scopeDetails: decisions[0].scopeDetails - }, - { - id: decisions[1].id, - scope: decisions[1].scope, - scopeDetails: decisions[1].scopeDetails - } - ]; - const modules = { - foo() {} - }; - - beforeEach(() => { - logger = jasmine.createSpyObj("logger", ["info", "warn", "error"]); - collect = jasmine.createSpy(); - executeActions = jasmine.createSpy(); - }); - - it("should trigger executeActions when provided with an array of actions", () => { - executeActions.and.returnValues( - [{ meta: metas[0] }, { meta: metas[0] }], - [{ meta: metas[1], error: "could not render this item" }] - ); - const executeDecisions = createExecuteDecisions({ - modules, - logger, - executeActions - }); - return executeDecisions(decisions).then(() => { - expect(executeActions).toHaveBeenCalledWith( - expectedAction, - modules, - logger - ); - expect(logger.warn).toHaveBeenCalledWith({ - meta: metas[1], - error: "could not render this item" - }); - }); - }); - - it("shouldn't trigger executeActions when provided with empty array of actions", () => { - executeActions.and.callThrough(); - const executeDecisions = createExecuteDecisions({ - modules, - logger, - executeActions, - collect - }); - return executeDecisions([]).then(() => { - expect(executeActions).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/test/unit/specs/components/Personalization/createFetchDataHandler.spec.js b/test/unit/specs/components/Personalization/createFetchDataHandler.spec.js index d35788f0d..4d4b583a9 100644 --- a/test/unit/specs/components/Personalization/createFetchDataHandler.spec.js +++ b/test/unit/specs/components/Personalization/createFetchDataHandler.spec.js @@ -11,86 +11,181 @@ governing permissions and limitations under the License. */ import createFetchDataHandler from "../../../../../src/components/Personalization/createFetchDataHandler"; +import injectCreateProposition from "../../../../../src/components/Personalization/handlers/injectCreateProposition"; +import flushPromiseChains from "../../../helpers/flushPromiseChains"; +import defer from "../../../../../src/utils/defer"; describe("Personalization::createFetchDataHandler", () => { - let responseHandler; + let prehidingStyle; + let showContainers; let hideContainers; let mergeQuery; + let collect; + let processPropositions; + let createProposition; + let pendingDisplayNotifications; + + let cacheUpdate; let personalizationDetails; - let decisionsDeferred; - const config = { - prehidingStyle: "body {opacity:0;}" - }; - let onResponse = jasmine.createSpy(); - const event = {}; + let event; + let onResponse; + let response; beforeEach(() => { - response = jasmine.createSpyObj("response", ["getPayloadsByType"]); - responseHandler = jasmine.createSpy(); - mergeQuery = jasmine.createSpy(); + prehidingStyle = "myprehidingstyle"; + showContainers = jasmine.createSpy("showContainers"); + hideContainers = jasmine.createSpy("hideContainers"); + mergeQuery = jasmine.createSpy("mergeQuery"); + collect = jasmine.createSpy("collect"); + processPropositions = jasmine.createSpy("processPropositions"); + createProposition = injectCreateProposition({ + preprocess: data => data, + isPageWideSurface: () => false + }); + pendingDisplayNotifications = jasmine.createSpyObj( + "pendingDisplayNotifications", + ["concat"] + ); + + cacheUpdate = jasmine.createSpyObj("cacheUpdate", ["update"]); personalizationDetails = jasmine.createSpyObj("personalizationDetails", [ "isRenderDecisions", - "createQueryDetails" + "createQueryDetails", + "getViewName", + "isSendDisplayNotifications" ]); - hideContainers = jasmine.createSpy("hideContainers"); - decisionsDeferred = jasmine.createSpyObj("decisionsDeferred", ["reject"]); + personalizationDetails.createQueryDetails.and.returnValue("myquerydetails"); + personalizationDetails.isSendDisplayNotifications.and.returnValue(true); + event = "myevent"; + onResponse = jasmine.createSpy(); + response = jasmine.createSpyObj("response", ["getPayloadsByType"]); }); - it("should hide containers if renderDecisions is true", () => { + const run = () => { const fetchDataHandler = createFetchDataHandler({ - config, - responseHandler, + prehidingStyle, + showContainers, hideContainers, - mergeQuery + mergeQuery, + collect, + processPropositions, + createProposition, + pendingDisplayNotifications }); - personalizationDetails.isRenderDecisions.and.returnValue(true); - fetchDataHandler({ - decisionsDeferred, + cacheUpdate, personalizationDetails, event, onResponse }); + }; + + const returnResponse = () => { + expect(onResponse).toHaveBeenCalledTimes(1); + const callback = onResponse.calls.argsFor(0)[0]; + return callback({ response }); + }; + + it("should hide containers if renderDecisions is true", () => { + personalizationDetails.isRenderDecisions.and.returnValue(true); + run(); expect(hideContainers).toHaveBeenCalled(); }); + it("shouldn't hide containers if renderDecisions is false", () => { - const fetchDataHandler = createFetchDataHandler({ - config, - responseHandler, - hideContainers, - mergeQuery - }); personalizationDetails.isRenderDecisions.and.returnValue(false); - fetchDataHandler({ - decisionsDeferred, - personalizationDetails, - event, - onResponse - }); - + run(); expect(hideContainers).not.toHaveBeenCalled(); }); it("should trigger responseHandler at onResponse", () => { - const fetchDataHandler = createFetchDataHandler({ - config, - responseHandler, - hideContainers, - mergeQuery - }); personalizationDetails.isRenderDecisions.and.returnValue(false); - onResponse = callback => { - callback(response); + run(); + response.getPayloadsByType.and.returnValue([]); + cacheUpdate.update.and.returnValue([]); + processPropositions.and.returnValue({ + returnedPropositions: [], + returnedDecisions: [] + }); + const result = returnResponse(); + expect(result).toEqual({ + propositions: [], + decisions: [] + }); + }); + + it("should render decisions", async () => { + personalizationDetails.isRenderDecisions.and.returnValue(true); + personalizationDetails.getViewName.and.returnValue("myviewname"); + processPropositions = () => { + return { + render: () => Promise.resolve([{ id: "handle1" }]), + returnedPropositions: [ + { id: "handle1", items: ["item1"], renderAttempted: true } + ], + returnedDecisions: [] + }; }; - fetchDataHandler({ - decisionsDeferred, - personalizationDetails, - event, - onResponse + run(); + response.getPayloadsByType.and.returnValue([ + { + id: "handle1", + scopeDetails: { characteristics: { scopeType: "view" } } + }, + { id: "handle2" } + ]); + cacheUpdate.update.and.returnValue([createProposition({ id: "handle1" })]); + const result = returnResponse(); + expect(result).toEqual({ + propositions: [ + { id: "handle1", items: ["item1"], renderAttempted: true } + ], + decisions: [] + }); + await flushPromiseChains(); + expect(showContainers).toHaveBeenCalled(); + expect(collect).toHaveBeenCalledOnceWith({ + decisionsMeta: [{ id: "handle1" }], + viewName: "myviewname" }); + }); - expect(hideContainers).not.toHaveBeenCalled(); - expect(responseHandler).toHaveBeenCalled(); + it("should show containers immediately", async () => { + personalizationDetails.isRenderDecisions.and.returnValue(true); + const renderDeferred = defer(); + processPropositions = () => { + return { + render: () => renderDeferred.promise, + returnedPropositions: [ + { + id: "handle2", + scope: "__view__", + items: ["item1"], + renderAttempted: true + } + ], + returnedDecisions: [] + }; + }; + run(); + response.getPayloadsByType.and.returnValue([ + { + id: "handle2", + scope: "__view__", + items: ["item1"] + } + ]); + cacheUpdate.update.and.returnValue([]); + expect(showContainers).not.toHaveBeenCalled(); + returnResponse(); + expect(showContainers).toHaveBeenCalled(); + expect(collect).not.toHaveBeenCalled(); + renderDeferred.resolve([{ id: "handle2" }]); + await flushPromiseChains(); + expect(collect).toHaveBeenCalledOnceWith({ + decisionsMeta: [{ id: "handle2" }], + viewName: undefined + }); }); }); diff --git a/test/unit/specs/components/Personalization/createNonRenderingHandler.spec.js b/test/unit/specs/components/Personalization/createNonRenderingHandler.spec.js deleted file mode 100644 index bc54c8646..000000000 --- a/test/unit/specs/components/Personalization/createNonRenderingHandler.spec.js +++ /dev/null @@ -1,86 +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 { - CART_VIEW_DECISIONS, - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS, - REDIRECT_PAGE_WIDE_SCOPE_DECISION, - SCOPES_FOO1_FOO2_DECISIONS -} from "./responsesMock/eventResponses"; -import createNonRenderingHandler from "../../../../../src/components/Personalization/createNonRenderingHandler"; - -describe("Personalization::createNonRenderingHandler", () => { - let viewCache; - let pageWideScopeDecisions; - let nonAutoRenderableDecisions; - let cartViewDecisions; - let redirectDecisions; - - beforeEach(() => { - redirectDecisions = REDIRECT_PAGE_WIDE_SCOPE_DECISION; - cartViewDecisions = CART_VIEW_DECISIONS; - pageWideScopeDecisions = PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS; - nonAutoRenderableDecisions = SCOPES_FOO1_FOO2_DECISIONS; - viewCache = jasmine.createSpyObj("viewCache", ["getView"]); - }); - - it("it should fetch decisions from cache when viewName is present", () => { - const viewName = "cart"; - const promise = { - then: callback => callback(cartViewDecisions) - }; - viewCache.getView.and.returnValue(promise); - - const nonRenderingHandler = createNonRenderingHandler({ - viewCache - }); - - return nonRenderingHandler({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).toHaveBeenCalledWith("cart"); - expect(result.decisions.length).toBe(6); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toEqual(false); - }); - }); - }); - - it("it should not trigger viewCache when no viewName", () => { - const viewName = undefined; - const nonRenderingHandler = createNonRenderingHandler({ - viewCache - }); - - return nonRenderingHandler({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).not.toHaveBeenCalled(); - expect(result.decisions.length).toBe(5); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toEqual(false); - }); - }); - }); -}); diff --git a/test/unit/specs/components/Personalization/createOnResponseHandler.spec.js b/test/unit/specs/components/Personalization/createOnResponseHandler.spec.js deleted file mode 100644 index c70da3deb..000000000 --- a/test/unit/specs/components/Personalization/createOnResponseHandler.spec.js +++ /dev/null @@ -1,206 +0,0 @@ -/* -Copyright 2020 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 { - CART_VIEW_DECISIONS, - PAGE_WIDE_SCOPE_DECISIONS, - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS, - PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS, - PRODUCTS_VIEW_DECISIONS, - REDIRECT_PAGE_WIDE_SCOPE_DECISION -} from "./responsesMock/eventResponses"; -import createOnResponseHandler from "../../../../../src/components/Personalization/createOnResponseHandler"; - -describe("Personalization::onResponseHandler", () => { - const nonDomActionDecisions = PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS; - const unprocessedDecisions = [ - ...PAGE_WIDE_SCOPE_DECISIONS, - ...CART_VIEW_DECISIONS, - ...PRODUCTS_VIEW_DECISIONS - ]; - const pageWideScopeDecisions = PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS; - - let groupDecisions; - let autoRenderingHandler; - let nonRenderingHandler; - let showContainers; - let response; - let personalizationDetails; - let decisionsDeferred; - let handleRedirectDecisions; - - beforeEach(() => { - response = jasmine.createSpyObj("response", ["getPayloadsByType"]); - personalizationDetails = jasmine.createSpyObj("personalizationDetails", [ - "isRenderDecisions", - "getViewName" - ]); - groupDecisions = jasmine.createSpy(); - decisionsDeferred = jasmine.createSpyObj("decisionsDeferred", [ - "defer", - "reject", - "resolve" - ]); - autoRenderingHandler = jasmine.createSpy("autoRenderingHandler"); - showContainers = jasmine.createSpy("showContainers"); - nonRenderingHandler = jasmine.createSpy("nonRenderingHandler"); - handleRedirectDecisions = jasmine.createSpy("handleRedirectDecisions"); - }); - - it("should trigger autoRenderingHandler when renderDecisions is true", () => { - const nonPageWideScopeDecisions = { - cart: CART_VIEW_DECISIONS, - products: PRODUCTS_VIEW_DECISIONS - }; - groupDecisions.and.returnValues({ - redirectDecisions: [], - pageWideScopeDecisions, - viewDecisions: nonPageWideScopeDecisions, - nonAutoRenderableDecisions: nonDomActionDecisions - }); - - response.getPayloadsByType.and.returnValue(unprocessedDecisions); - personalizationDetails.isRenderDecisions.and.returnValue(true); - personalizationDetails.getViewName.and.returnValue(undefined); - const onResponse = createOnResponseHandler({ - window, - groupDecisions, - nonRenderingHandler, - autoRenderingHandler, - handleRedirectDecisions, - showContainers - }); - - onResponse({ - decisionsDeferred, - personalizationDetails, - response - }); - expect(decisionsDeferred.resolve).toHaveBeenCalledWith( - nonPageWideScopeDecisions - ); - expect(autoRenderingHandler).toHaveBeenCalledWith({ - viewName: undefined, - pageWideScopeDecisions, - nonAutoRenderableDecisions: nonDomActionDecisions - }); - }); - it("should trigger nonRenderingHandler when renderDecisions is false", () => { - const nonPageWideScopeDecisions = { - products: PRODUCTS_VIEW_DECISIONS - }; - groupDecisions.and.returnValues({ - redirectDecisions: [], - pageWideScopeDecisions, - viewDecisions: nonPageWideScopeDecisions, - nonAutoRenderableDecisions: nonDomActionDecisions - }); - - response.getPayloadsByType.and.returnValue(unprocessedDecisions); - personalizationDetails.isRenderDecisions.and.returnValue(false); - personalizationDetails.getViewName.and.returnValue(undefined); - const onResponse = createOnResponseHandler({ - window, - groupDecisions, - nonRenderingHandler, - autoRenderingHandler, - handleRedirectDecisions, - showContainers - }); - - onResponse({ - decisionsDeferred, - personalizationDetails, - response - }); - expect(decisionsDeferred.resolve).toHaveBeenCalledWith( - nonPageWideScopeDecisions - ); - expect(nonRenderingHandler).toHaveBeenCalledWith({ - viewName: undefined, - redirectDecisions: [], - pageWideScopeDecisions, - nonAutoRenderableDecisions: nonDomActionDecisions - }); - }); - - it("should trigger showContainers if personalizationDetails payload is empty and return empty array", () => { - const expectedResult = { - decisions: [], - propositions: [] - }; - response.getPayloadsByType.and.returnValue([]); - personalizationDetails.isRenderDecisions.and.returnValue(false); - personalizationDetails.getViewName.and.returnValue("cart"); - - const onResponse = createOnResponseHandler({ - window, - groupDecisions, - nonRenderingHandler, - autoRenderingHandler, - handleRedirectDecisions, - showContainers - }); - const result = onResponse({ - decisionsDeferred, - personalizationDetails, - response - }); - expect(decisionsDeferred.resolve).toHaveBeenCalledWith({}); - expect(showContainers).toHaveBeenCalled(); - expect(groupDecisions).not.toHaveBeenCalled(); - expect(nonRenderingHandler).not.toHaveBeenCalled(); - expect(autoRenderingHandler).not.toHaveBeenCalled(); - expect(result).toEqual(expectedResult); - }); - - it("should trigger redirect handler when renderDecisions is true and there are redirectDecisions", () => { - const payload = [ - ...PAGE_WIDE_SCOPE_DECISIONS, - ...CART_VIEW_DECISIONS, - ...PRODUCTS_VIEW_DECISIONS, - ...REDIRECT_PAGE_WIDE_SCOPE_DECISION - ]; - const nonPageWideScopeDecisions = { - cart: CART_VIEW_DECISIONS, - products: PRODUCTS_VIEW_DECISIONS - }; - groupDecisions.and.returnValues({ - redirectDecisions: REDIRECT_PAGE_WIDE_SCOPE_DECISION, - pageWideScopeDecisions, - viewDecisions: nonPageWideScopeDecisions, - nonAutoRenderableDecisions: nonDomActionDecisions - }); - - response.getPayloadsByType.and.returnValue(payload); - personalizationDetails.isRenderDecisions.and.returnValue(true); - personalizationDetails.getViewName.and.returnValue("cart"); - const onResponse = createOnResponseHandler({ - window, - groupDecisions, - nonRenderingHandler, - autoRenderingHandler, - handleRedirectDecisions, - showContainers - }); - const result = onResponse({ - decisionsDeferred, - personalizationDetails, - response - }); - expect(decisionsDeferred.resolve).toHaveBeenCalledWith({}); - expect(showContainers).not.toHaveBeenCalled(); - expect(nonRenderingHandler).not.toHaveBeenCalled(); - expect(autoRenderingHandler).not.toHaveBeenCalled(); - expect(result).toEqual(undefined); - }); -}); diff --git a/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js b/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js index 6ebe0fb65..c3671c583 100644 --- a/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js +++ b/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js @@ -31,12 +31,10 @@ describe("Personalization::createPersonalizationDetails", () => { const getPageLocation = createGetPageLocation({ window }); let event; - let viewCache; let logger; beforeEach(() => { event = jasmine.createSpyObj("event", ["getViewName"]); - viewCache = jasmine.createSpyObj("viewCache", ["getView", "isInitialized"]); logger = jasmine.createSpyObj("logger", ["info", "warn", "error"]); }); @@ -51,10 +49,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: false, logger }); - viewCache.isInitialized.and.returnValue(false); const expectedDecisionScopes = [PAGE_WIDE_SCOPE]; const expectedQueryDetails = { schemas: [ @@ -89,10 +86,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: false, logger }); - viewCache.isInitialized.and.returnValue(false); const expectedDecisionScopes = [PAGE_WIDE_SCOPE]; const expectedQueryDetails = { schemas: [ @@ -127,10 +123,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: false, logger }); - viewCache.isInitialized.and.returnValue(false); const expectedDecisionScopes = ["test1", "__view__"]; const expectedQueryDetails = { schemas: [ @@ -165,10 +160,9 @@ describe("Personalization::createPersonalizationDetails", () => { personalization, decisionScopes, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); const expectedDecisionScopes = ["test1"]; const expectedQueryDetails = { schemas: [ @@ -204,10 +198,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); const expectedDecisionScopes = []; const expectedQueryDetails = { schemas: [ @@ -244,10 +237,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); const expectedDecisionScopes = ["test1", "test2"]; const expectedQueryDetails = { @@ -281,10 +273,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); expect(personalizationDetails.isRenderDecisions()).toEqual(true); expect(personalizationDetails.hasScopes()).toEqual(false); @@ -305,10 +296,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); expect(personalizationDetails.isRenderDecisions()).toEqual(false); expect(personalizationDetails.hasScopes()).toEqual(false); @@ -329,10 +319,9 @@ describe("Personalization::createPersonalizationDetails", () => { personalization, decisionScopes, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); expect(personalizationDetails.isRenderDecisions()).toEqual(true); expect(personalizationDetails.hasScopes()).toEqual(false); @@ -353,10 +342,9 @@ describe("Personalization::createPersonalizationDetails", () => { personalization, decisionScopes, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); expect(personalizationDetails.isRenderDecisions()).toEqual(false); expect(personalizationDetails.hasScopes()).toEqual(false); @@ -379,10 +367,9 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: true, logger }); - viewCache.isInitialized.and.returnValue(true); const expectedDecisionScopes = ["__view__"]; const expectedQueryDetails = { @@ -417,7 +404,7 @@ describe("Personalization::createPersonalizationDetails", () => { decisionScopes, personalization, event, - viewCache, + isCacheInitialized: false, logger }); expect(personalizationDetails.isRenderDecisions()).toEqual(true); diff --git a/test/unit/specs/components/Personalization/createRedirectHandler.spec.js b/test/unit/specs/components/Personalization/createRedirectHandler.spec.js deleted file mode 100644 index 0bad6680e..000000000 --- a/test/unit/specs/components/Personalization/createRedirectHandler.spec.js +++ /dev/null @@ -1,75 +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 { REDIRECT_PAGE_WIDE_SCOPE_DECISION } from "./responsesMock/eventResponses"; -import createRedirectHandler from "../../../../../src/components/Personalization/createRedirectHandler"; - -describe("Personalization::createRedirectDecisionHandler", () => { - let collect; - let showContainers; - let logger; - - const documentMayUnload = true; - const decisions = REDIRECT_PAGE_WIDE_SCOPE_DECISION; - const decisionsMeta = [ - { - id: decisions[0].id, - scope: decisions[0].scope, - scopeDetails: decisions[0].scopeDetails - } - ]; - const replace = jasmine.createSpy(); - - const window = { - location: { replace } - }; - - beforeEach(() => { - collect = jasmine.createSpy().and.returnValue(Promise.resolve()); - logger = jasmine.createSpyObj("logger", ["warn"]); - showContainers = jasmine.createSpy("showContainers"); - }); - - it("should trigger collect before redirect", () => { - const handleRedirectDecisions = createRedirectHandler({ - collect, - window, - logger, - showContainers - }); - return handleRedirectDecisions(decisions).then(() => { - expect(collect).toHaveBeenCalledWith({ - decisionsMeta, - documentMayUnload - }); - expect(replace).toHaveBeenCalledWith(decisions[0].items[0].data.content); - }); - }); - it("should trigger showContainers and logger when redirect fails", () => { - replace.and.throwError("Malformed url"); - - const handleRedirectDecisions = createRedirectHandler({ - collect, - window, - logger, - showContainers - }); - return handleRedirectDecisions(decisions).then(() => { - expect(collect).toHaveBeenCalledWith({ - decisionsMeta, - documentMayUnload - }); - expect(showContainers).toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalled(); - }); - }); -}); diff --git a/test/unit/specs/components/Personalization/createViewCacheManager.spec.js b/test/unit/specs/components/Personalization/createViewCacheManager.spec.js index 0c7e2ec89..fcbf24cd0 100644 --- a/test/unit/specs/components/Personalization/createViewCacheManager.spec.js +++ b/test/unit/specs/components/Personalization/createViewCacheManager.spec.js @@ -10,75 +10,95 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +import { DEFAULT_CONTENT_ITEM } from "../../../../../src/components/Personalization/constants/schema"; import createViewCacheManager from "../../../../../src/components/Personalization/createViewCacheManager"; -import { defer } from "../../../../../src/utils"; -describe("Personalization::createCacheManager", () => { - const cartView = "cart"; - const homeView = "home"; - const productsView = "products"; - const viewDecisions = { - home: [ - { - id: "foo1", - items: [], - scope: "home" - }, - { - id: "foo2", - items: [], - scope: "home" - } - ], - cart: [ - { - id: "foo3", - items: [], - scope: "cart" - } - ] - }; - - it("stores and gets the decisions based on a viewName", () => { - const viewCacheManager = createViewCacheManager(); - const decisionsDeferred = defer(); +describe("Personalization::createViewCacheManager", () => { + const viewHandles = [ + { + id: "foo1", + scope: "home" + }, + { + id: "foo2", + scope: "home" + }, + { + id: "foo3", + scope: "cart" + }, + { + id: "foo4", + scope: "other" + } + ]; - viewCacheManager.storeViews(decisionsDeferred.promise); - decisionsDeferred.resolve(viewDecisions); + let createProposition; + let propositions; - return Promise.all([ - expectAsync(viewCacheManager.getView(cartView)).toBeResolvedTo( - viewDecisions[cartView] - ), - expectAsync(viewCacheManager.getView(homeView)).toBeResolvedTo( - viewDecisions[homeView] - ) - ]); + beforeEach(() => { + createProposition = viewHandle => { + const { scope } = viewHandle; + return { + getScope() { + return scope; + }, + toJSON() { + return viewHandle; + } + }; + }; + propositions = viewHandles.map(createProposition); }); - it("gets an empty array if there is no decisions for a specific view", () => { - const viewCacheManager = createViewCacheManager(); - const decisionsDeferred = defer(); + it("stores and gets the decisions based on a viewName", async () => { + const viewCacheManager = createViewCacheManager({ createProposition }); - viewCacheManager.storeViews(decisionsDeferred.promise); - decisionsDeferred.resolve(viewDecisions); + const cacheUpdate = viewCacheManager.createCacheUpdate("home"); + const resultingHandles = cacheUpdate.update(propositions); + expect(resultingHandles).toEqual([propositions[0], propositions[1]]); - return Promise.all([ - expectAsync(viewCacheManager.getView(productsView)).toBeResolvedTo([]) - ]); + const homeViews = await viewCacheManager.getView("home"); + expect(homeViews).toEqual([propositions[0], propositions[1]]); + + const cartViews = await viewCacheManager.getView("cart"); + expect(cartViews).toEqual([propositions[2]]); + + const otherViews = await viewCacheManager.getView("other"); + expect(otherViews).toEqual([propositions[3]]); }); - it("should be no views when decisions deferred is rejected", () => { - const viewCacheManager = createViewCacheManager(); - const decisionsDeferred = defer(); + it("should be no views when decisions deferred is rejected", async () => { + const viewCacheManager = createViewCacheManager({ createProposition }); + const cacheUpdate = viewCacheManager.createCacheUpdate("home"); + cacheUpdate.cancel(); + + const homeViews = await viewCacheManager.getView("home"); + expect(homeViews.map(h => h.toJSON())).toEqual([ + { + scope: "home", + scopeDetails: { + characteristics: { + scopeType: "view" + } + }, + items: [ + { + schema: DEFAULT_CONTENT_ITEM + } + ] + } + ]); + }); - viewCacheManager.storeViews(decisionsDeferred.promise); - decisionsDeferred.reject(); + it("should not be initialized when first created", () => { + const viewCacheManager = createViewCacheManager({ createProposition }); + expect(viewCacheManager.isInitialized()).toBe(false); + }); - return expectAsync(viewCacheManager.getView("cart")) - .toBeResolvedTo([]) - .then(() => { - expect(viewCacheManager.isInitialized()).toBeTrue(); - }); + it("should be initialized when first cache update is created", () => { + const viewCacheManager = createViewCacheManager({ createProposition }); + viewCacheManager.createCacheUpdate("home"); + expect(viewCacheManager.isInitialized()).toBe(true); }); }); diff --git a/test/unit/specs/components/Personalization/createViewChangeHandler.spec.js b/test/unit/specs/components/Personalization/createViewChangeHandler.spec.js index 134fd4d79..7f7f87d2c 100644 --- a/test/unit/specs/components/Personalization/createViewChangeHandler.spec.js +++ b/test/unit/specs/components/Personalization/createViewChangeHandler.spec.js @@ -11,109 +11,67 @@ governing permissions and limitations under the License. */ import createViewChangeHandler from "../../../../../src/components/Personalization/createViewChangeHandler"; -import { PropositionEventType } from "../../../../../src/components/Personalization/constants/propositionEventType"; import { CART_VIEW_DECISIONS } from "./responsesMock/eventResponses"; +import injectCreateProposition from "../../../../../src/components/Personalization/handlers/injectCreateProposition"; describe("Personalization::createViewChangeHandler", () => { - let personalizationDetails; + let processPropositions; let viewCache; - const event = {}; - const onResponse = callback => callback(); - let executeDecisions; - let showContainers; - let mergeDecisionsMeta; - let collect; + let personalizationDetails; + let event; + let onResponse; + + let createProposition; beforeEach(() => { + processPropositions = jasmine.createSpy("processPropositions"); + viewCache = jasmine.createSpyObj("viewCache", ["getView"]); + personalizationDetails = jasmine.createSpyObj("personalizationDetails", [ "isRenderDecisions", "getViewName" ]); - viewCache = jasmine.createSpyObj("viewCache", ["getView"]); - executeDecisions = jasmine.createSpy("executeDecisions"); - showContainers = jasmine.createSpy("showContainers"); - mergeDecisionsMeta = jasmine.createSpy("mergeDecisionsMeta"); - collect = jasmine.createSpy("collect"); - }); - - it("should trigger executeDecisions if renderDecisions is true", () => { - const cartViewPromise = { - then: callback => callback(CART_VIEW_DECISIONS) - }; - - viewCache.getView.and.returnValue(cartViewPromise); - executeDecisions.and.returnValue(cartViewPromise); - personalizationDetails.isRenderDecisions.and.returnValue(true); - personalizationDetails.getViewName.and.returnValue("cart"); + event = "myevent"; + onResponse = jasmine.createSpy(); - const viewChangeHandler = createViewChangeHandler({ - mergeDecisionsMeta, - collect, - executeDecisions, - viewCache - }); - - viewChangeHandler({ - event, - personalizationDetails, - onResponse + createProposition = injectCreateProposition({ + preprocess: data => data, + isPageWideSurface: () => false }); - expect(executeDecisions).toHaveBeenCalledWith(CART_VIEW_DECISIONS); - expect(mergeDecisionsMeta).toHaveBeenCalledWith( - event, - CART_VIEW_DECISIONS, - PropositionEventType.DISPLAY - ); - expect(collect).not.toHaveBeenCalled(); }); - it("should not trigger executeDecisions when render decisions is false", () => { - const cartViewPromise = { - then: callback => callback(CART_VIEW_DECISIONS) - }; - viewCache.getView.and.returnValue(cartViewPromise); - personalizationDetails.isRenderDecisions.and.returnValue(false); - personalizationDetails.getViewName.and.returnValue("cart"); - + const run = async () => { const viewChangeHandler = createViewChangeHandler({ - executeDecisions, - viewCache, - showContainers + processPropositions, + viewCache }); - - viewChangeHandler({ + const decisionsMeta = await viewChangeHandler({ event, personalizationDetails, onResponse }); - expect(executeDecisions).not.toHaveBeenCalled(); - expect(collect).not.toHaveBeenCalled(); - }); + const result = onResponse.calls.argsFor(0)[0](); + return { decisionsMeta, result }; + }; - it("at onResponse it should trigger collect call when no decisions in cache", () => { - const cartViewPromise = { - then: callback => callback([]) - }; - - viewCache.getView.and.returnValue(cartViewPromise); - executeDecisions.and.returnValue(cartViewPromise); + it("should trigger render if renderDecisions is true", async () => { + viewCache.getView.and.returnValue( + Promise.resolve(CART_VIEW_DECISIONS.map(p => createProposition(p))) + ); personalizationDetails.isRenderDecisions.and.returnValue(true); personalizationDetails.getViewName.and.returnValue("cart"); - - const viewChangeHandler = createViewChangeHandler({ - mergeDecisionsMeta, - collect, - executeDecisions, - viewCache + processPropositions.and.returnValue({ + render: () => Promise.resolve("decisionMeta"), + returnedPropositions: [], + returnedDecisions: CART_VIEW_DECISIONS }); - viewChangeHandler({ - event, - personalizationDetails, - onResponse - }); - expect(executeDecisions).toHaveBeenCalledWith([]); - expect(collect).toHaveBeenCalled(); + const { decisionsMeta, result } = await run(); + + expect(processPropositions).toHaveBeenCalledTimes(1); + expect(decisionsMeta).toEqual("decisionMeta"); + + expect(result.decisions).toEqual(CART_VIEW_DECISIONS); }); }); diff --git a/test/unit/specs/components/Personalization/dom-actions/createPreprocess.spec.js b/test/unit/specs/components/Personalization/dom-actions/createPreprocess.spec.js new file mode 100644 index 000000000..41a07e3d1 --- /dev/null +++ b/test/unit/specs/components/Personalization/dom-actions/createPreprocess.spec.js @@ -0,0 +1,40 @@ +import createPreprocess from "../../../../../../src/components/Personalization/dom-actions/createPreprocess"; + +describe("Personalization::dom-actions::createPreprocess", () => { + let preprocessor1; + let preprocessor2; + let preprocess; + beforeEach(() => { + preprocessor1 = jasmine.createSpy("preprocessor1"); + preprocessor2 = jasmine.createSpy("preprocessor2"); + preprocess = createPreprocess([preprocessor1, preprocessor2]); + }); + + it("handles an empty action", () => { + expect(preprocess({})).toEqual({}); + }); + + it("passes the data through", () => { + preprocessor1.and.callFake(data => data); + preprocessor2.and.callFake(data => data); + expect(preprocess({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); + }); + + it("passes the data through when the preprocessor returns undefined", () => { + preprocessor1.and.callFake(() => undefined); + preprocessor2.and.callFake(() => undefined); + expect(preprocess({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); + }); + + it("updates the data", () => { + preprocessor1.and.callFake(() => ({ c: 3 })); + preprocessor2.and.callFake(() => ({ d: 4 })); + expect(preprocess({ a: 1, b: 2 })).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); + + it("updates the data2", () => { + preprocessor1.and.callFake(data => ({ ...data, c: 3 })); + preprocessor2.and.callFake(data => ({ ...data, d: 4 })); + expect(preprocess({ a: 1, b: 2 })).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); +}); diff --git a/test/unit/specs/components/Personalization/dom-actions/executeActions.spec.js b/test/unit/specs/components/Personalization/dom-actions/executeActions.spec.js deleted file mode 100644 index 029dcaba3..000000000 --- a/test/unit/specs/components/Personalization/dom-actions/executeActions.spec.js +++ /dev/null @@ -1,136 +0,0 @@ -/* -Copyright 2019 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 executeActions from "../../../../../../src/components/Personalization/dom-actions/executeActions"; - -describe("Personalization::executeActions", () => { - it("should execute actions", () => { - const actionSpy = jasmine.createSpy().and.returnValue(Promise.resolve(1)); - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - logger.enabled = true; - const actions = [{ type: "foo" }]; - const modules = { - foo: actionSpy - }; - - return executeActions(actions, modules, logger).then(result => { - expect(result).toEqual([1]); - expect(actionSpy).toHaveBeenCalled(); - expect(logger.info.calls.count()).toEqual(1); - expect(logger.error).not.toHaveBeenCalled(); - }); - }); - - it("should preprocess actions", () => { - const customCodeActionSpy = jasmine - .createSpy("customCodeActionSpy") - .and.returnValue(Promise.resolve(9)); - - const setHtmlActionSpy = jasmine - .createSpy("setHtmlActionSpy") - .and.returnValue(Promise.resolve(1)); - const appendHtmlActionSpy = jasmine - .createSpy("appendHtmlActionSpy") - .and.returnValue(Promise.resolve(2)); - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - logger.enabled = true; - const actions = [ - { - type: "setHtml", - selector: "head", - content: - '

Unsupported tag content

' - }, - { - type: "customCode", - selector: "BODY > *:eq(0)", - content: "
superfluous
" - } - ]; - const modules = { - setHtml: setHtmlActionSpy, - appendHtml: appendHtmlActionSpy, - customCode: customCodeActionSpy - }; - - return executeActions(actions, modules, logger).then(result => { - expect(result).toEqual([2, 9]); - expect(setHtmlActionSpy).not.toHaveBeenCalled(); - expect(appendHtmlActionSpy).toHaveBeenCalledOnceWith( - jasmine.objectContaining({ - type: "appendHtml", - selector: "head", - content: '' - }) - ); - expect(logger.info.calls.count()).toEqual(2); - expect(logger.error).not.toHaveBeenCalled(); - - expect(customCodeActionSpy).toHaveBeenCalledOnceWith( - jasmine.objectContaining({ - type: "customCode", - selector: "BODY", - content: "
superfluous
" - }) - ); - }); - }); - - it("should not invoke logger.info when logger is not enabled", () => { - const actionSpy = jasmine.createSpy().and.returnValue(Promise.resolve(1)); - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - logger.enabled = false; - const actions = [{ type: "foo" }]; - const modules = { - foo: actionSpy - }; - return executeActions(actions, modules, logger).then(result => { - expect(result).toEqual([1]); - expect(actionSpy).toHaveBeenCalled(); - expect(logger.info.calls.count()).toEqual(0); - expect(logger.error).not.toHaveBeenCalled(); - }); - }); - - it("should throw error when execute actions fails", () => { - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - logger.enabled = true; - const actions = [{ type: "foo" }]; - const modules = { - foo: jasmine.createSpy().and.throwError("foo's error") - }; - - expect(() => executeActions(actions, modules, logger)).toThrowError(); - }); - - it("should log nothing when there are no actions", () => { - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - const actions = []; - const modules = {}; - - return executeActions(actions, modules, logger).then(result => { - expect(result).toEqual([]); - expect(logger.info).not.toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); - }); - }); - - it("should throw error when there are no actions types", () => { - const logger = jasmine.createSpyObj("logger", ["error", "info"]); - logger.enabled = true; - const actions = [{ type: "foo1" }]; - const modules = { - foo: () => {} - }; - expect(() => executeActions(actions, modules, logger)).toThrowError(); - }); -}); diff --git a/test/unit/specs/components/Personalization/dom-actions/initDomActionsModules.spec.js b/test/unit/specs/components/Personalization/dom-actions/initDomActionsModules.spec.js index a308c7fca..6f671a9e2 100644 --- a/test/unit/specs/components/Personalization/dom-actions/initDomActionsModules.spec.js +++ b/test/unit/specs/components/Personalization/dom-actions/initDomActionsModules.spec.js @@ -31,8 +31,6 @@ const buildSet = () => { result.add("replaceHtml"); result.add("prependHtml"); result.add("appendHtml"); - result.add("click"); - result.add("defaultContent"); return result; }; diff --git a/test/unit/specs/components/Personalization/groupDecisions.spec.js b/test/unit/specs/components/Personalization/groupDecisions.spec.js deleted file mode 100644 index fe07dedda..000000000 --- a/test/unit/specs/components/Personalization/groupDecisions.spec.js +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2020 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 { - PAGE_WIDE_SCOPE_DECISIONS, - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS, - PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS, - CART_VIEW_DECISIONS, - REDIRECT_PAGE_WIDE_SCOPE_DECISION, - PRODUCTS_VIEW_DECISIONS, - MERGED_METRIC_DECISIONS -} from "./responsesMock/eventResponses"; -import groupDecisions from "../../../../../src/components/Personalization/groupDecisions"; - -let cartDecisions; -let productDecisions; -let mergedDecisions; - -beforeEach(() => { - cartDecisions = PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS.concat( - CART_VIEW_DECISIONS - ); - productDecisions = PAGE_WIDE_SCOPE_DECISIONS.concat( - REDIRECT_PAGE_WIDE_SCOPE_DECISION - ).concat(PRODUCTS_VIEW_DECISIONS); - mergedDecisions = productDecisions.concat(MERGED_METRIC_DECISIONS); -}); - -describe("Personalization::groupDecisions", () => { - it("extracts decisions by scope", () => { - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(cartDecisions); - - expect(pageWideScopeDecisions).toEqual( - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - ); - expect(viewDecisions).toEqual({ cart: CART_VIEW_DECISIONS }); - expect(nonAutoRenderableDecisions).toEqual([]); - expect(redirectDecisions).toEqual([]); - }); - - it("extracts decisions", () => { - const expectedViewDecisions = { - products: PRODUCTS_VIEW_DECISIONS - }; - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(productDecisions); - - expect(nonAutoRenderableDecisions).toEqual( - PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS - ); - expect(pageWideScopeDecisions).toEqual( - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - ); - expect(redirectDecisions).toEqual(REDIRECT_PAGE_WIDE_SCOPE_DECISION); - expect(viewDecisions).toEqual(expectedViewDecisions); - }); - - it("extracts merged decisions", () => { - const expectedViewDecisions = { - products: PRODUCTS_VIEW_DECISIONS - }; - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(mergedDecisions); - - expect(nonAutoRenderableDecisions).toEqual( - MERGED_METRIC_DECISIONS.concat( - PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS - ) - ); - expect(pageWideScopeDecisions).toEqual( - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - ); - expect(redirectDecisions).toEqual(REDIRECT_PAGE_WIDE_SCOPE_DECISION); - expect(viewDecisions).toEqual(expectedViewDecisions); - }); - - it("extracts empty when no decisions", () => { - const decisions = []; - - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(decisions); - - expect(nonAutoRenderableDecisions).toEqual([]); - expect(pageWideScopeDecisions).toEqual([]); - expect(redirectDecisions).toEqual([]); - expect(viewDecisions).toEqual({}); - }); -}); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js new file mode 100644 index 000000000..ffaaa2170 --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/createProcessDomAction.spec.js @@ -0,0 +1,100 @@ +import createProcessDomAction from "../../../../../../src/components/Personalization/handlers/createProcessDomAction"; + +describe("createProcessDomAction", () => { + let item; + let data; + let meta; + let modules; + let logger; + let storeClickMetrics; + let processDomAction; + + beforeEach(() => { + item = { + getData() { + return data; + }, + getMeta() { + return meta; + } + }; + modules = { + typeA: jasmine.createSpy("typeA"), + typeB: jasmine.createSpy("typeB") + }; + logger = jasmine.createSpyObj("logger", ["warn"]); + storeClickMetrics = jasmine.createSpy("storeClickMetrics"); + + processDomAction = createProcessDomAction({ + modules, + logger, + storeClickMetrics + }); + }); + + it("returns an empty object if the item has no data, and logs missing type", () => { + data = undefined; + expect(processDomAction(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid DOM action data: missing type.", + undefined + ); + }); + + it("returns an empty object if the item has no type, and logs missing type", () => { + data = {}; + expect(processDomAction(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid DOM action data: missing type.", + {} + ); + }); + + it("returns an empty object if the item has an unknown type, and logs unknown type", () => { + data = { type: "typeC" }; + expect(processDomAction(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid DOM action data: unknown type.", + { + type: "typeC" + } + ); + }); + + it("returns an empty object if the item has no selector for a click type, and logs missing selector", () => { + data = { type: "click" }; + expect(processDomAction(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid DOM action data: missing selector.", + { + type: "click" + } + ); + }); + + it("handles a click type", () => { + data = { type: "click", selector: ".selector" }; + meta = "mymetavalue"; + expect(processDomAction(item)).toEqual({ + setRenderAttempted: true, + includeInNotification: false + }); + expect(storeClickMetrics).toHaveBeenCalledWith({ + selector: ".selector", + meta: "mymetavalue" + }); + }); + + it("handles a non-click known type", () => { + data = { type: "typeA", a: "b" }; + const result = processDomAction(item); + expect(result).toEqual({ + render: jasmine.any(Function), + setRenderAttempted: true, + includeInNotification: true + }); + expect(modules.typeA).not.toHaveBeenCalled(); + result.render(); + expect(modules.typeA).toHaveBeenCalledWith({ type: "typeA", a: "b" }); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js new file mode 100644 index 000000000..cef24684e --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/createProcessHtmlContent.spec.js @@ -0,0 +1,69 @@ +import createProcessHtmlContent from "../../../../../../src/components/Personalization/handlers/createProcessHtmlContent"; + +describe("createProcessHtmlContent", () => { + let modules; + let logger; + let item; + let data; + let processHtmlContent; + + beforeEach(() => { + modules = { + typeA: jasmine.createSpy("typeA"), + typeB: jasmine.createSpy("typeB") + }; + logger = jasmine.createSpyObj("logger", ["warn"]); + item = { + getData() { + return data; + } + }; + + processHtmlContent = createProcessHtmlContent({ modules, logger }); + }); + + it("returns an empty object if the item has no data", () => { + data = undefined; + expect(processHtmlContent(item)).toEqual({}); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an empty object if the item has no type", () => { + data = { selector: ".myselector" }; + expect(processHtmlContent(item)).toEqual({}); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an empty object if the item has no selector", () => { + data = { type: "mytype" }; + expect(processHtmlContent(item)).toEqual({}); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an empty object if the item has an unknown type, and logs unknown type", () => { + data = { type: "typeC", selector: ".myselector", content: "mycontent" }; + expect(processHtmlContent(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith("Invalid HTML content data", { + type: "typeC", + selector: ".myselector", + content: "mycontent" + }); + }); + + it("handles a known type", () => { + data = { type: "typeA", selector: ".myselector", content: "mycontent" }; + const result = processHtmlContent(item); + expect(result).toEqual({ + render: jasmine.any(Function), + setRenderAttempted: true, + includeInNotification: true + }); + expect(modules.typeA).not.toHaveBeenCalled(); + result.render(); + expect(modules.typeA).toHaveBeenCalledWith({ + type: "typeA", + selector: ".myselector", + content: "mycontent" + }); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessPropositions.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessPropositions.spec.js new file mode 100644 index 000000000..10c726577 --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/createProcessPropositions.spec.js @@ -0,0 +1,302 @@ +import createProcessPropositions from "../../../../../../src/components/Personalization/handlers/createProcessPropositions"; +import injectCreateProposition from "../../../../../../src/components/Personalization/handlers/injectCreateProposition"; + +describe("createProcessPropositions", () => { + let schemaProcessors; + let logger; + let createProposition; + let processPropositions; + + let render; + let always; + let noNotification; + let never; + let noRender; + let redirect; + + beforeEach(() => { + render = jasmine.createSpy("render"); + always = item => ({ + render: () => render(item.getData()), + setRenderAttempted: true, + includeInNotification: true + }); + noNotification = item => ({ + render: () => render(item.getData()), + setRenderAttempted: true, + includeInNotification: false + }); + never = () => ({}); + noRender = () => ({ + setRenderAttempted: true, + includeInNotification: true + }); + redirect = item => ({ + render: () => render(item.getData()), + setRenderAttempted: true, + onlyRenderThis: true + }); + + schemaProcessors = { always, noNotification, never, noRender, redirect }; + logger = jasmine.createSpyObj("logger", ["info", "error"]); + processPropositions = createProcessPropositions({ + schemaProcessors, + logger + }); + createProposition = injectCreateProposition({ preprocess: data => data }); + }); + + it("handles no propositions", async () => { + const result = processPropositions([]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [], + returnedDecisions: [] + }); + await expectAsync(result.render()).toBeResolvedTo([]); + }); + + it("processes a proposition with an always item", async () => { + const prop1 = createProposition({ + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata" }] + }); + const result = processPropositions([prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata" }], + renderAttempted: true + } + ], + returnedDecisions: [] + }); + expect(render).not.toHaveBeenCalled(); + await expectAsync(result.render()).toBeResolvedTo([ + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 } + } + ]); + expect(render).toHaveBeenCalledWith("mydata"); + }); + + it("processes a proposition with a noNotification item", async () => { + const prop1 = createProposition({ + id: "noNotification1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "noNotification", data: "mydata" }] + }); + const result = processPropositions([prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "noNotification1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "noNotification", data: "mydata" }], + renderAttempted: true + } + ], + returnedDecisions: [] + }); + expect(render).not.toHaveBeenCalled(); + await expectAsync(result.render()).toBeResolvedTo([]); + expect(render).toHaveBeenCalledWith("mydata"); + }); + + it("processes a proposition with a never item", async () => { + const prop1 = createProposition({ + id: "never1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "never", data: "mydata" }] + }); + const result = processPropositions([prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "never1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "never", data: "mydata" }], + renderAttempted: false + } + ], + returnedDecisions: [ + { + id: "never1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "never", data: "mydata" }] + } + ] + }); + await expectAsync(result.render()).toBeResolvedTo([]); + expect(render).not.toHaveBeenCalled(); + }); + + it("processes a proposition with a noRender item", async () => { + const prop1 = createProposition({ + id: "noRender1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "noRender", data: "mydata" }] + }); + const result = processPropositions([prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "noRender1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "noRender", data: "mydata" }], + renderAttempted: true + } + ], + returnedDecisions: [] + }); + await expectAsync(result.render()).toBeResolvedTo([ + { + id: "noRender1", + scope: "myscope", + scopeDetails: { a: 1 } + } + ]); + expect(render).not.toHaveBeenCalled(); + }); + + it("processes a proposition with a redirect item", async () => { + const prop1 = createProposition({ + id: "redirect1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "redirect", data: "mydata" }] + }); + const result = processPropositions([prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "redirect1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "redirect", data: "mydata" }], + renderAttempted: true + } + ], + returnedDecisions: [] + }); + expect(render).not.toHaveBeenCalled(); + await expectAsync(result.render()).toBeResolvedTo([]); + expect(render).toHaveBeenCalledWith("mydata"); + }); + + it("doesn't render other propositions if one has a redirect", async () => { + const prop1 = createProposition({ + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata1" }] + }); + const prop2 = createProposition({ + id: "redirect2", + scope: "myscope", + scopeDetails: { a: 2 }, + items: [{ schema: "redirect", data: "mydata2" }] + }); + const prop3 = createProposition({ + id: "always3", + scope: "myscope", + scopeDetails: { a: 3 }, + items: [{ schema: "always", data: "mydata3" }] + }); + const result = processPropositions([prop1, prop2, prop3]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "redirect2", + scope: "myscope", + scopeDetails: { a: 2 }, + items: [{ schema: "redirect", data: "mydata2" }], + renderAttempted: true + }, + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata1" }], + renderAttempted: false + }, + { + id: "always3", + scope: "myscope", + scopeDetails: { a: 3 }, + items: [{ schema: "always", data: "mydata3" }], + renderAttempted: false + } + ], + returnedDecisions: [ + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata1" }] + }, + { + id: "always3", + scope: "myscope", + scopeDetails: { a: 3 }, + items: [{ schema: "always", data: "mydata3" }] + } + ] + }); + expect(render).not.toHaveBeenCalled(); + await expectAsync(result.render()).toBeResolvedTo([]); + expect(render).toHaveBeenCalledWith("mydata2"); + }); + + it("processes nonRenderPropositions", async () => { + const prop1 = createProposition({ + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata" }] + }); + const result = processPropositions([], [prop1]); + expect(result).toEqual({ + render: jasmine.any(Function), + returnedPropositions: [ + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata" }], + renderAttempted: false + } + ], + returnedDecisions: [ + { + id: "always1", + scope: "myscope", + scopeDetails: { a: 1 }, + items: [{ schema: "always", data: "mydata" }] + } + ] + }); + await expectAsync(result.render()).toBeResolvedTo([]); + expect(render).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/createProcessRedirect.spec.js b/test/unit/specs/components/Personalization/handlers/createProcessRedirect.spec.js new file mode 100644 index 000000000..f69b91f7d --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/createProcessRedirect.spec.js @@ -0,0 +1,83 @@ +import { defer } from "../../../../../../src/utils"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; +import createProcessRedirect from "../../../../../../src/components/Personalization/handlers/createProcessRedirect"; + +describe("createProcessRedirect", () => { + let logger; + let executeRedirect; + let collect; + let collectDefer; + let item; + let data; + let meta; + + let processRedirect; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["warn"]); + executeRedirect = jasmine.createSpy("executeRedirect"); + collectDefer = defer(); + collect = jasmine + .createSpy("collect") + .and.returnValue(collectDefer.promise); + item = { + getData() { + return data; + }, + getMeta() { + return meta; + } + }; + + processRedirect = createProcessRedirect({ + logger, + executeRedirect, + collect + }); + }); + + it("returns an empty object if the item has no data", () => { + data = undefined; + expect(processRedirect(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith( + "Invalid Redirect data", + undefined + ); + }); + + it("returns an empty object if the item has no content", () => { + data = { a: 1 }; + expect(processRedirect(item)).toEqual({}); + expect(logger.warn).toHaveBeenCalledWith("Invalid Redirect data", { a: 1 }); + }); + + it("redirects", async () => { + data = { content: "mycontent" }; + meta = "mymetavalue"; + const result = processRedirect(item); + expect(result).toEqual({ + render: jasmine.any(Function), + setRenderAttempted: true, + onlyRenderThis: true + }); + expect(collect).not.toHaveBeenCalled(); + expect(executeRedirect).not.toHaveBeenCalled(); + const renderPromise = result.render(); + await flushPromiseChains(); + expect(collect).toHaveBeenCalledWith({ decisionsMeta: ["mymetavalue"] }); + expect(executeRedirect).not.toHaveBeenCalled(); + collectDefer.resolve(); + await flushPromiseChains(); + expect(executeRedirect).toHaveBeenCalledWith("mycontent"); + expect(await renderPromise).toBeUndefined(); + }); + + it("doesn't eat the exception", async () => { + data = { content: "mycontent" }; + meta = "mymetavalue"; + const result = processRedirect(item); + const renderPromise = result.render(); + collectDefer.reject("myerror"); + await expectAsync(renderPromise).toBeRejectedWith("myerror"); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/injectCreateProposition.spec.js b/test/unit/specs/components/Personalization/handlers/injectCreateProposition.spec.js new file mode 100644 index 000000000..6266137f0 --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/injectCreateProposition.spec.js @@ -0,0 +1,75 @@ +import injectCreateProposition from "../../../../../../src/components/Personalization/handlers/injectCreateProposition"; + +describe("injectCreateProposition", () => { + const preprocess = data => `preprocessed ${data}`; + const isPageWideSurface = scope => scope === "__surface__"; + const createProposition = injectCreateProposition({ + preprocess, + isPageWideSurface + }); + + it("creates a proposition from nothing", () => { + const proposition = createProposition({}); + + expect(proposition.getScope()).toBeUndefined(); + expect(proposition.getScopeType()).toEqual("proposition"); + expect(proposition.getItems()).toEqual([]); + expect(proposition.getNotification()).toEqual({ + id: undefined, + scope: undefined, + scopeDetails: undefined + }); + expect(proposition.toJSON()).toEqual({}); + }); + + it("creates a full proposition", () => { + const proposition = createProposition({ + id: "id", + scope: "scope", + scopeDetails: { characteristics: { scopeType: "view" } }, + items: [ + { + schema: "schema", + data: "data", + characteristics: { trackingLabel: "trackingLabel" } + } + ] + }); + + expect(proposition.getScope()).toEqual("scope"); + expect(proposition.getScopeType()).toEqual("view"); + const item = proposition.getItems()[0]; + expect(item.getSchema()).toEqual("schema"); + expect(item.getData()).toEqual("preprocessed data"); + expect(item.getMeta()).toEqual({ + id: "id", + scope: "scope", + scopeDetails: { characteristics: { scopeType: "view" } }, + trackingLabel: "trackingLabel" + }); + expect(item.getOriginalItem()).toEqual({ + schema: "schema", + data: "data", + characteristics: { trackingLabel: "trackingLabel" } + }); + expect(proposition.getNotification()).toEqual({ + id: "id", + scope: "scope", + scopeDetails: { characteristics: { scopeType: "view" } } + }); + }); + + it("creates a page wide surface proposition", () => { + const proposition = createProposition({ + scope: "__surface__" + }); + expect(proposition.getScopeType()).toEqual("page"); + }); + + it("creates a page wide scope proposition", () => { + const proposition = createProposition({ + scope: "__view__" + }); + expect(proposition.getScopeType()).toEqual("page"); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/processDefaultContent.spec.js b/test/unit/specs/components/Personalization/handlers/processDefaultContent.spec.js new file mode 100644 index 000000000..998661639 --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/processDefaultContent.spec.js @@ -0,0 +1,11 @@ +import processDefaultContent from "../../../../../../src/components/Personalization/handlers/processDefaultContent"; + +describe("processDefaultContent", () => { + it("always renders the default content", () => { + const result = processDefaultContent(); + expect(result).toEqual({ + setRenderAttempted: true, + includeInNotification: true + }); + }); +}); diff --git a/test/unit/specs/components/Personalization/responsesMock/eventResponses.js b/test/unit/specs/components/Personalization/responsesMock/eventResponses.js index 329b340c5..9482fb28c 100644 --- a/test/unit/specs/components/Personalization/responsesMock/eventResponses.js +++ b/test/unit/specs/components/Personalization/responsesMock/eventResponses.js @@ -374,8 +374,7 @@ export const MIXED_PROPOSITIONS = [ id: "1202449" } } - ], - renderAttempted: false + ] }, { id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", @@ -401,7 +400,6 @@ export const MIXED_PROPOSITIONS = [ { id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", scope: "__view__", - renderAttempted: true, items: [ { id: "442358", @@ -417,7 +415,6 @@ export const MIXED_PROPOSITIONS = [ { id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn2=", scope: "__view__", - renderAttempted: false, items: [ { id: "442379", diff --git a/test/unit/specs/components/Personalization/topLevel/buildAlloy.js b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js new file mode 100644 index 000000000..0f9ee0d2e --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js @@ -0,0 +1,195 @@ +/* +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 createEvent from "../../../../../../src/core/createEvent"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; +import createComponent from "../../../../../../src/components/Personalization/createComponent"; +import createCollect from "../../../../../../src/components/Personalization/createCollect"; +import createFetchDataHandler from "../../../../../../src/components/Personalization/createFetchDataHandler"; +import collectClicks from "../../../../../../src/components/Personalization/dom-actions/clicks/collectClicks"; +import isAuthoringModeEnabled from "../../../../../../src/components/Personalization/utils/isAuthoringModeEnabled"; +import { + mergeDecisionsMeta, + mergeQuery +} from "../../../../../../src/components/Personalization/event"; +import createOnClickHandler from "../../../../../../src/components/Personalization/createOnClickHandler"; +import createViewCacheManager from "../../../../../../src/components/Personalization/createViewCacheManager"; +import createViewChangeHandler from "../../../../../../src/components/Personalization/createViewChangeHandler"; +import createClickStorage from "../../../../../../src/components/Personalization/createClickStorage"; +import createApplyPropositions from "../../../../../../src/components/Personalization/createApplyPropositions"; +import createSetTargetMigration from "../../../../../../src/components/Personalization/createSetTargetMigration"; +import { createCallbackAggregator, assign } from "../../../../../../src/utils"; +import injectCreateProposition from "../../../../../../src/components/Personalization/handlers/injectCreateProposition"; +import createProcessPropositions from "../../../../../../src/components/Personalization/handlers/createProcessPropositions"; +import createAsyncArray from "../../../../../../src/components/Personalization/utils/createAsyncArray"; +import * as schema from "../../../../../../src/components/Personalization/constants/schema"; +import createProcessDomAction from "../../../../../../src/components/Personalization/handlers/createProcessDomAction"; +import createProcessHtmlContent from "../../../../../../src/components/Personalization/handlers/createProcessHtmlContent"; +import createProcessRedirect from "../../../../../../src/components/Personalization/handlers/createProcessRedirect"; +import processDefaultContent from "../../../../../../src/components/Personalization/handlers/processDefaultContent"; +import { isPageWideSurface } from "../../../../../../src/components/Personalization/utils/surfaceUtils"; + +const createAction = renderFunc => ({ selector, content }) => { + renderFunc(selector, content); + if (selector === "#error") { + return Promise.reject(new Error(`Error while rendering ${content}`)); + } + return Promise.resolve(); +}; + +const buildComponent = ({ + actions, + config, + logger, + eventManager, + getPageLocation, + window, + hideContainers, + showContainers +}) => { + const initDomActionsModulesMocks = () => { + return { + setHtml: createAction(actions.setHtml), + customCode: createAction(actions.prependHtml), + setText: createAction(actions.setText), + setAttribute: createAction(actions.setAttributes), + setImageSource: createAction(actions.swapImage), + setStyle: createAction(actions.setStyles), + move: createAction(actions.setStyles), + resize: createAction(actions.setStyles), + rearrange: createAction(actions.rearrangeChildren), + remove: createAction(actions.removeNode), + insertAfter: createAction(actions.insertHtmlAfter), + insertBefore: createAction(actions.insertHtmlBefore), + replaceHtml: createAction(actions.replaceHtml), + appendHtml: createAction(actions.appendHtml), + prependHtml: createAction(actions.prependHtml) + }; + }; + + const { targetMigrationEnabled, prehidingStyle } = config; + const collect = createCollect({ eventManager, mergeDecisionsMeta }); + + const { + getClickMetasBySelector, + getClickSelectors, + storeClickMetrics + } = createClickStorage(); + + const preprocess = action => action; + const createProposition = injectCreateProposition({ + preprocess, + isPageWideSurface + }); + + const viewCache = createViewCacheManager({ createProposition }); + const modules = initDomActionsModulesMocks(); + + const schemaProcessors = { + [schema.DEFAULT_CONTENT_ITEM]: processDefaultContent, + [schema.DOM_ACTION]: createProcessDomAction({ + modules, + logger, + storeClickMetrics + }), + [schema.HTML_CONTENT_ITEM]: createProcessHtmlContent({ modules, logger }), + [schema.REDIRECT_ITEM]: createProcessRedirect({ + logger, + executeRedirect: url => window.location.replace(url), + collect + }) + }; + + const processPropositions = createProcessPropositions({ + schemaProcessors, + logger + }); + + const pendingDisplayNotifications = createAsyncArray(); + const fetchDataHandler = createFetchDataHandler({ + prehidingStyle, + showContainers, + hideContainers, + mergeQuery, + collect, + processPropositions, + createProposition, + pendingDisplayNotifications + }); + const onClickHandler = createOnClickHandler({ + mergeDecisionsMeta, + collectClicks, + getClickSelectors, + getClickMetasBySelector + }); + const viewChangeHandler = createViewChangeHandler({ + processPropositions, + viewCache + }); + const applyPropositions = createApplyPropositions({ + processPropositions, + createProposition, + pendingDisplayNotifications, + viewCache + }); + const setTargetMigration = createSetTargetMigration({ + targetMigrationEnabled + }); + return createComponent({ + getPageLocation, + logger, + fetchDataHandler, + viewChangeHandler, + onClickHandler, + isAuthoringModeEnabled, + mergeQuery, + viewCache, + showContainers, + applyPropositions, + setTargetMigration, + mergeDecisionsMeta, + pendingDisplayNotifications + }); +}; + +export default mocks => { + const component = buildComponent(mocks); + const { response } = mocks; + return { + async sendEvent({ + xdm, + data, + renderDecisions, + decisionScopes, + personalization + }) { + const event = createEvent(); + event.setUserXdm(xdm); + event.setUserData(data); + const callbacks = createCallbackAggregator(); + await component.lifecycle.onBeforeEvent({ + event, + renderDecisions, + decisionScopes, + personalization: personalization || { sendDisplayNotifications: true }, + onResponse: callbacks.add + }); + const results = await callbacks.call({ response }); + const result = assign({}, ...results); + await flushPromiseChains(); + event.finalize(); + return { event, result }; + }, + applyPropositions(args) { + return component.commands.applyPropositions.run(args); + } + }; +}; diff --git a/test/unit/specs/components/Personalization/topLevel/buildMocks.js b/test/unit/specs/components/Personalization/topLevel/buildMocks.js new file mode 100644 index 000000000..be2023ca3 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/buildMocks.js @@ -0,0 +1,77 @@ +/* +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 createEvent from "../../../../../../src/core/createEvent"; +import createResponse from "../../../../../functional/helpers/createResponse"; + +export default decisions => { + const response = createResponse({ + content: { + handle: decisions.map(payload => ({ + type: "personalization:decisions", + payload + })) + } + }); + + const actions = jasmine.createSpyObj("actions", [ + "createAction", + "setHtml", + "setText", + "setAttributes", + "swapImage", + "setStyles", + "rearrangeChildren", + "removeNode", + "replaceHtml", + "appendHtml", + "prependHtml", + "insertHtmlAfter", + "insertHtmlBefore" + ]); + + const config = { + targetMigrationEnabled: true, + prehidingStyle: "myprehidingstyle" + }; + const logger = { + warn: spyOn(console, "warn").and.callThrough(), + error: spyOn(console, "error").and.callThrough() + }; + const sendEvent = jasmine.createSpy("sendEvent"); + const eventManager = { + createEvent, + async sendEvent(event) { + event.finalize(); + sendEvent(event.toJSON()); + return Promise.resolve(); + } + }; + const getPageLocation = () => new URL("http://example.com/home"); + const window = { + location: jasmine.createSpyObj("location", ["replace"]) + }; + const hideContainers = jasmine.createSpy("hideContainers"); + const showContainers = jasmine.createSpy("showContainers"); + + return { + actions, + config, + logger, + sendEvent, + eventManager, + getPageLocation, + window, + hideContainers, + showContainers, + response + }; +}; diff --git a/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js b/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js new file mode 100644 index 000000000..6aa3faca5 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js @@ -0,0 +1,257 @@ +import { CART_VIEW_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; +import resetMocks from "./resetMocks"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; + +describe("PersonalizationComponent", () => { + it("CART_VIEW_DECISIONS", async () => { + const mocks = buildMocks(CART_VIEW_DECISIONS); + const alloy = buildAlloy(mocks); + let { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + CART_VIEW_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [], + decisions: [] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + + resetMocks(mocks); + ({ event, result } = await alloy.sendEvent( + { + renderDecisions: true, + xdm: { + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }, + [] + )); + + expect(event.toJSON()).toEqual({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity4:experience9", + scope: "cart", + scopeDetails: { + blah: "test", + characteristics: { + scopeType: "view" + } + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: true, + id: "TNT:activity4:experience9", + scope: "cart", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
welcome to cart view
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity for cart view
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + blah: "test", + characteristics: { + scopeType: "view" + } + } + } + ], + decisions: [] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
welcome to cart view
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity for cart view
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledTimes(2); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); + + it("CART_VIEW_DECISIONS 2", async () => { + const mocks = buildMocks(CART_VIEW_DECISIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true, + xdm: { + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }, + CART_VIEW_DECISIONS + ); + + await flushPromiseChains(); + + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + }, + xdm: { + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: true, + id: "TNT:activity4:experience9", + scope: "cart", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
welcome to cart view
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity for cart view
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + blah: "test", + characteristics: { + scopeType: "view" + } + } + } + ], + decisions: [] + }); + expect(mocks.sendEvent).toHaveBeenCalledWith({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity4:experience9", + scope: "cart", + scopeDetails: { + blah: "test", + characteristics: { + scopeType: "view" + } + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + eventType: "decisioning.propositionDisplay", + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
welcome to cart view
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity for cart view
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledTimes(2); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js b/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js new file mode 100644 index 000000000..3f77d60be --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js @@ -0,0 +1,107 @@ +import { MERGED_METRIC_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("MERGED_METRIC_DECISIONS", async () => { + const mocks = buildMocks(MERGED_METRIC_DECISIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + MERGED_METRIC_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: false, + id: "TNT:activity6:experience1", + scope: "testScope", + items: [ + { + id: "0", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + id: "0", + format: "text/html", + content: "testScope content1" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + }, + { + schema: "https://ns.adobe.com/personalization/measurement", + data: { + type: "click", + format: "application/vnd.adobe.target.metric" + } + } + ], + scopeDetails: { + eventTokens: { + display: "displayToken1", + click: "clickToken1" + } + } + } + ], + decisions: [ + { + id: "TNT:activity6:experience1", + scope: "testScope", + items: [ + { + id: "0", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + id: "0", + format: "text/html", + content: "testScope content1" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + }, + { + schema: "https://ns.adobe.com/personalization/measurement", + data: { + type: "click", + format: "application/vnd.adobe.target.metric" + } + } + ], + scopeDetails: { + eventTokens: { + display: "displayToken1", + click: "clickToken1" + } + } + } + ] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js b/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js new file mode 100644 index 000000000..d378ca581 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js @@ -0,0 +1,273 @@ +import { MIXED_PROPOSITIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; +import resetMocks from "./resetMocks"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; + +describe("PersonalizationComponent", () => { + it("MIXED_PROPOSITIONS", async () => { + const mocks = buildMocks(MIXED_PROPOSITIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + MIXED_PROPOSITIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result.propositions).toEqual( + jasmine.arrayWithExactContents([ + { + renderAttempted: true, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "__view__", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + }, + { + renderAttempted: true, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn2=", + scope: "__view__", + items: [ + { + id: "442379", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442359", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + content: "

Some custom content for the home page

", + format: "text/html", + id: "1202448" + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442360", + schema: "https://ns.adobe.com/personalization/json-content-item", + data: { + content: "{'field1': 'custom content'}", + format: "text/javascript", + id: "1202449" + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", + scope: "home", + items: [ + { + id: "xcore:personalized-offer:134ce877e13a04ca", + etag: "4", + schema: + "https://ns.adobe.com/experience/offer-management/content-component-html", + data: { + id: "xcore:personalized-offer:134ce877e13a04ca", + format: "text/html", + language: ["en-us"], + content: "

An html offer from Offer Decisioning

", + characteristics: { + testing: "true" + } + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + } + ]) + ); + expect(result.decisions).toEqual( + jasmine.arrayWithExactContents([ + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442359", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + content: "

Some custom content for the home page

", + format: "text/html", + id: "1202448" + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442360", + schema: "https://ns.adobe.com/personalization/json-content-item", + data: { + content: "{'field1': 'custom content'}", + format: "text/javascript", + id: "1202449" + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", + scope: "home", + items: [ + { + id: "xcore:personalized-offer:134ce877e13a04ca", + etag: "4", + schema: + "https://ns.adobe.com/experience/offer-management/content-component-html", + data: { + id: "xcore:personalized-offer:134ce877e13a04ca", + format: "text/html", + language: ["en-us"], + content: "

An html offer from Offer Decisioning

", + characteristics: { + testing: "true" + } + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + } + ]) + ); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + + resetMocks(mocks); + const applyPropositionsResult = await alloy.applyPropositions({ + propositions: result.propositions, + metadata: { + home: { + selector: "#myhomeselector", + actionType: "appendHtml" + } + } + }); + expect(applyPropositionsResult.propositions).toEqual([ + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ], + renderAttempted: true, + scopeDetails: undefined + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442359", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + content: "

Some custom content for the home page

", + format: "text/html", + id: "1202448", + selector: "#myhomeselector", + type: "appendHtml" + } + } + ], + renderAttempted: true, + scopeDetails: undefined + } + ]); + expect(applyPropositionsResult.decisions).toBeUndefined(); + + await flushPromiseChains(); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + expect(mocks.actions.appendHtml).toHaveBeenCalledOnceWith( + "#myhomeselector", + "

Some custom content for the home page

" + ); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js b/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js new file mode 100644 index 000000000..3d936ca45 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js @@ -0,0 +1,144 @@ +import { PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS", async () => { + const mocks = buildMocks(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: true, + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
Hola Mundo
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + blah: "test" + } + }, + { + renderAttempted: true, + id: "AJO:campaign1:message1", + scope: "web://alloy.test.com/test/page/1", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
Hola Mundo
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + decisionProvider: "AJO" + } + } + ], + decisions: [] + }); + expect(mocks.sendEvent).toHaveBeenCalledWith({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity1:experience1", + scope: "__view__", + scopeDetails: { + blah: "test" + } + }, + { + id: "AJO:campaign1:message1", + scope: "web://alloy.test.com/test/page/1", + scopeDetails: { + decisionProvider: "AJO" + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + eventType: "decisioning.propositionDisplay" + } + }); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
Hola Mundo
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
Hola Mundo
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledTimes(4); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js b/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js new file mode 100644 index 000000000..d4d9fec2c --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js @@ -0,0 +1,194 @@ +import { PAGE_WIDE_SCOPE_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("PAGE_WIDE_SCOPE_DECISIONS", async () => { + const mocks = buildMocks(PAGE_WIDE_SCOPE_DECISIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + PAGE_WIDE_SCOPE_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result.propositions).toEqual( + jasmine.arrayWithExactContents([ + { + renderAttempted: true, + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
Hola Mundo
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + blah: "test" + } + }, + { + renderAttempted: true, + id: "AJO:campaign1:message1", + scope: "web://alloy.test.com/test/page/1", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "
Hola Mundo
" + } + }, + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo2", + content: "
here is a target activity
" + } + }, + { + schema: + "https://ns.adobe.com/personalization/default-content-item" + } + ], + scopeDetails: { + decisionProvider: "AJO" + } + }, + { + renderAttempted: false, + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + }, + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "B", + content: "Banner B ...." + } + } + ], + scopeDetails: { + blah: "test" + } + } + ]) + ); + expect(result.decisions).toEqual( + jasmine.arrayWithExactContents([ + { + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + }, + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "B", + content: "Banner B ...." + } + } + ], + scopeDetails: { + blah: "test" + } + } + ]) + ); + expect(mocks.sendEvent).toHaveBeenCalledWith({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity1:experience1", + scope: "__view__", + scopeDetails: { + blah: "test" + } + }, + { + id: "AJO:campaign1:message1", + scope: "web://alloy.test.com/test/page/1", + scopeDetails: { + decisionProvider: "AJO" + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + eventType: "decisioning.propositionDisplay" + } + }); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
Hola Mundo
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo", + "
Hola Mundo
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledWith( + "#foo2", + "
here is a target activity
" + ); + expect(mocks.actions.setHtml).toHaveBeenCalledTimes(4); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js b/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js new file mode 100644 index 000000000..fc96d24bd --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js @@ -0,0 +1,91 @@ +import { PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS", async () => { + const mocks = buildMocks( + PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS + ); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + PAGE_WIDE_SCOPE_DECISIONS_WITHOUT_DOM_ACTION_SCHEMA_ITEMS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: false, + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + }, + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "B", + content: "Banner B ...." + } + } + ], + scopeDetails: { + blah: "test" + } + } + ], + decisions: [ + { + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + }, + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "B", + content: "Banner B ...." + } + } + ], + scopeDetails: { + blah: "test" + } + } + ] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js b/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js new file mode 100644 index 000000000..0f7c72002 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js @@ -0,0 +1,40 @@ +import { PRODUCTS_VIEW_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("PRODUCTS_VIEW_DECISIONS", async () => { + const mocks = buildMocks(PRODUCTS_VIEW_DECISIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + PRODUCTS_VIEW_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [], + decisions: [] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js b/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js new file mode 100644 index 000000000..92c6d984c --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js @@ -0,0 +1,57 @@ +import { REDIRECT_PAGE_WIDE_SCOPE_DECISION } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("REDIRECT_PAGE_WIDE_SCOPE_DECISION", async () => { + const mocks = buildMocks(REDIRECT_PAGE_WIDE_SCOPE_DECISION); + const alloy = buildAlloy(mocks); + const { event } = await alloy.sendEvent( + { + renderDecisions: true + }, + REDIRECT_PAGE_WIDE_SCOPE_DECISION + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + // No expectation on the result value because the page will redirect soon. + expect(mocks.sendEvent).toHaveBeenCalledWith({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity15:experience1", + scope: "__view__", + scopeDetails: { + blah: "test" + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + eventType: "decisioning.propositionDisplay" + } + }); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/resetMocks.js b/test/unit/specs/components/Personalization/topLevel/resetMocks.js new file mode 100644 index 000000000..202d917ae --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/resetMocks.js @@ -0,0 +1,18 @@ +export default ({ + actions, + logger, + sendEvent, + window, + hideContainers, + showContainers +}) => { + Object.keys(actions).forEach(key => { + actions[key].calls.reset(); + }); + logger.warn.calls.reset(); + logger.error.calls.reset(); + sendEvent.calls.reset(); + window.location.replace.calls.reset(); + hideContainers.calls.reset(); + showContainers.calls.reset(); +}; diff --git a/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js b/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js new file mode 100644 index 000000000..fad11f532 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js @@ -0,0 +1,137 @@ +import { SCOPES_FOO1_FOO2_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("SCOPES_FOO1_FOO2_DECISIONS", async () => { + const mocks = buildMocks(SCOPES_FOO1_FOO2_DECISIONS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + SCOPES_FOO1_FOO2_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + + expect(result).toEqual({ + propositions: [ + { + renderAttempted: false, + id: "TNT:ABC:A", + scope: "Foo1", + items: [ + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "1", + url: "https://foo.com/article/1", + thumbnailUrl: "https://foo.com/image/1?size=400x300" + } + }, + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "2", + url: "https://foo.com/article/2", + thumbnailUrl: "https://foo.com/image/2?size=400x300" + } + }, + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "3", + url: "https://foo.com/article/3", + thumbnailUrl: "https://foo.com/image/3?size=400x300" + } + } + ], + scopeDetails: { + blah: "test" + } + }, + { + renderAttempted: false, + id: "TNT:ABC:A", + scope: "Foo2", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + } + ] + } + ], + decisions: [ + { + id: "TNT:ABC:A", + scope: "Foo1", + items: [ + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "1", + url: "https://foo.com/article/1", + thumbnailUrl: "https://foo.com/image/1?size=400x300" + } + }, + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "2", + url: "https://foo.com/article/2", + thumbnailUrl: "https://foo.com/image/2?size=400x300" + } + }, + { + schema: "https://ns.adove.com/experience/item-article", + data: { + id: "3", + url: "https://foo.com/article/3", + thumbnailUrl: "https://foo.com/image/3?size=400x300" + } + } + ], + scopeDetails: { + blah: "test" + } + }, + { + id: "TNT:ABC:A", + scope: "Foo2", + items: [ + { + schema: "https://ns.adove.com/experience/item", + data: { + id: "A", + content: "Banner A ...." + } + } + ] + } + ] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/utils/composePersonalizationResultingObject.spec.js b/test/unit/specs/components/Personalization/utils/composePersonalizationResultingObject.spec.js deleted file mode 100644 index bb4eac352..000000000 --- a/test/unit/specs/components/Personalization/utils/composePersonalizationResultingObject.spec.js +++ /dev/null @@ -1,35 +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 composePersonalizationResultingObject from "../../../../../../src/components/Personalization/utils/composePersonalizationResultingObject"; - -describe("Personalization::composePersonalizationResultingObject", () => { - const decisions = [ - { - blah: "123" - }, - { - blah: "345" - } - ]; - it("adds a renderAttempted flag if renderDecisions is true", () => { - const result = composePersonalizationResultingObject(decisions, true); - expect(result.propositions[0].renderAttempted).toEqual(true); - expect(result.decisions).toBeUndefined(); - }); - - it("returns decisions without renderAttempted flag and propositions with renderAttempted false when render decisions is false", () => { - const result = composePersonalizationResultingObject(decisions, false); - expect(result.propositions[0].renderAttempted).toEqual(false); - expect(result.decisions).toEqual(decisions); - }); -}); diff --git a/test/unit/specs/components/Personalization/utils/createAsyncArray.spec.js b/test/unit/specs/components/Personalization/utils/createAsyncArray.spec.js new file mode 100644 index 000000000..c7557a5ae --- /dev/null +++ b/test/unit/specs/components/Personalization/utils/createAsyncArray.spec.js @@ -0,0 +1,35 @@ +import createAsyncArray from "../../../../../../src/components/Personalization/utils/createAsyncArray"; +import { defer } from "../../../../../../src/utils"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; + +describe("Personalization::utils::createAsyncArray", () => { + it("should start with an empty array", async () => { + const asyncArray = createAsyncArray(); + expect(await asyncArray.clear()).toEqual([]); + }); + + it("should add items to the array, and clear the items", async () => { + const asyncArray = createAsyncArray(); + await asyncArray.concat(Promise.resolve(["myitem1"])); + expect(await asyncArray.clear()).toEqual(["myitem1"]); + expect(await asyncArray.clear()).toEqual([]); + }); + + it("should add multiple arrays", async () => { + const asyncArray = createAsyncArray(); + await asyncArray.concat(Promise.resolve(["myitem1"])); + await asyncArray.concat(Promise.resolve(["myitem2"])); + expect(await asyncArray.clear()).toEqual(["myitem1", "myitem2"]); + }); + + it("should wait for items while clearing the array", async () => { + const asyncArray = createAsyncArray(); + const deferred = defer(); + asyncArray.concat(deferred.promise); + const clearPromise = asyncArray.clear(); + await flushPromiseChains(); + expectAsync(clearPromise).toBePending(); + deferred.resolve(["myitem1"]); + expect(await clearPromise).toEqual(["myitem1"]); + }); +}); diff --git a/test/unit/specs/components/Personalization/validateApplyPropositionsOptions.spec.js b/test/unit/specs/components/Personalization/validateApplyPropositionsOptions.spec.js index 90f4a333e..52558c967 100644 --- a/test/unit/specs/components/Personalization/validateApplyPropositionsOptions.spec.js +++ b/test/unit/specs/components/Personalization/validateApplyPropositionsOptions.spec.js @@ -82,7 +82,7 @@ describe("Personalization::validateApplyPropositionsOptions", () => { expect(result).not.toEqual(EMPTY_PROPOSITIONS); }); - it("it should log a warning when propositions is empty array", () => { + it("it should not log a warning when propositions is empty array", () => { const result = validateApplyPropositionsOptions({ logger, options: { @@ -90,7 +90,7 @@ describe("Personalization::validateApplyPropositionsOptions", () => { } }); - expect(loggerSpy).toHaveBeenCalled(); + expect(loggerSpy).not.toHaveBeenCalled(); expect(result).toEqual(EMPTY_PROPOSITIONS); }); diff --git a/test/unit/specs/core/createEvent.spec.js b/test/unit/specs/core/createEvent.spec.js index 2ca57f2dc..e81cd7b45 100644 --- a/test/unit/specs/core/createEvent.spec.js +++ b/test/unit/specs/core/createEvent.spec.js @@ -360,4 +360,43 @@ describe("createEvent", () => { }); }); }); + + it("deduplicates propositions by id", () => { + const subject = createEvent(); + subject.mergeXdm({ + _experience: { + decisioning: { + propositions: [ + { id: "1", scope: "a" }, + { id: "2", scope: "a" } + ] + } + } + }); + subject.setUserXdm({ + _experience: { + decisioning: { + propositions: [ + { id: "2", scope: "a" }, + { id: "3", scope: "a" }, + { id: "3", scope: "a" } + ] + } + } + }); + subject.finalize(); + expect(subject.toJSON()).toEqual({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { id: "2", scope: "a" }, + { id: "3", scope: "a" }, + { id: "1", scope: "a" } + ] + } + } + } + }); + }); }); diff --git a/test/unit/specs/utils/deduplicateArray.spec.js b/test/unit/specs/utils/deduplicateArray.spec.js new file mode 100644 index 000000000..f94b954a7 --- /dev/null +++ b/test/unit/specs/utils/deduplicateArray.spec.js @@ -0,0 +1,33 @@ +import { deduplicateArray } from "../../../../src/utils"; + +describe("deduplicateArray", () => { + it("should return an empty array if input is empty", () => { + expect(deduplicateArray([])).toEqual([]); + }); + + it("should return an array with one item if input has one item", () => { + const input = [1]; + expect(deduplicateArray(input)).toEqual(input); + }); + + it("should return an array with one item if input has two equal items", () => { + const input = [1, 1]; + expect(deduplicateArray(input)).toEqual([1]); + }); + + it("should return an array with two items if input has two different items", () => { + const input = [1, 2]; + expect(deduplicateArray(input)).toEqual(input); + }); + + it("should return an array with two items if input has three items with two equal items", () => { + const input = [1, 1, 2]; + expect(deduplicateArray(input)).toEqual([1, 2]); + }); + + it("should accept a custom equality function", () => { + const input = [{ id: 1 }, { id: 1 }, { id: 2 }]; + const isEqual = (a, b) => a.id === b.id; + expect(deduplicateArray(input, isEqual)).toEqual([{ id: 1 }, { id: 2 }]); + }); +});