From ce5b402cbd4d6537691362ab96a6881b4901ba70 Mon Sep 17 00:00:00 2001 From: Jeremy Zimmerman Date: Mon, 31 Oct 2022 12:24:09 -0400 Subject: [PATCH] Initial adds for analytics and logging. --- src/constants/analytics.js | 36 ++ src/constants/cookie-names.js | 12 + src/constants/experiments.js | 14 + .../heritage-integration/cookie-helper.js | 209 ++++++++ .../cookie-helper.spec.js | 145 ++++++ .../heritage-integration/navigation-helper.js | 150 ++++++ .../navigation-helper.spec.js | 445 ++++++++++++++++++ .../heritage-integration/order-helper.js | 113 +++++ .../heritage-integration/order-helper.spec.js | 236 ++++++++++ .../heritage-integration/session-helper.js | 47 ++ .../session-helper.spec.js | 89 ++++ src/helpers/unit-test-helper.js | 144 ++++++ src/mixins/analytics-mixin.js | 212 +++++++++ src/mixins/analytics-mixin.spec.js | 299 ++++++++++++ 14 files changed, 2151 insertions(+) create mode 100644 src/constants/analytics.js create mode 100644 src/constants/cookie-names.js create mode 100644 src/constants/experiments.js create mode 100644 src/helpers/heritage-integration/cookie-helper.js create mode 100644 src/helpers/heritage-integration/cookie-helper.spec.js create mode 100644 src/helpers/heritage-integration/navigation-helper.js create mode 100644 src/helpers/heritage-integration/navigation-helper.spec.js create mode 100644 src/helpers/heritage-integration/order-helper.js create mode 100644 src/helpers/heritage-integration/order-helper.spec.js create mode 100644 src/helpers/heritage-integration/session-helper.js create mode 100644 src/helpers/heritage-integration/session-helper.spec.js create mode 100644 src/helpers/unit-test-helper.js create mode 100644 src/mixins/analytics-mixin.js create mode 100644 src/mixins/analytics-mixin.spec.js diff --git a/src/constants/analytics.js b/src/constants/analytics.js new file mode 100644 index 00000000..eed7da2f --- /dev/null +++ b/src/constants/analytics.js @@ -0,0 +1,36 @@ +const analyticsPageEvents = { + ENTRY: "ENTRY", + EVENT: "EVENT", +}; + +// GA Constants +const GaEvents = { + GENERIC_EVENT: "event", + PAGE_VIEW_EVENT: "logPageview", +}; + +const GaCategories = { + API_RESPONSE: "Api_Response", + EVOX: "Evox", +}; + +const GaActions = { + RESULT: "Result", + CLICKED: "Clicked", + VIF: "vif", + SUBMITTED: "Submitted", +}; + +const GaLabels = { + SUCCESS: "Success", + ERROR: "Error", + LICENSE_PLATE_LOOKUP: "License_Plate_Look_Up", + VIN_LOOKUP: "Vin_Look_Up", + ADDRESS_LOOKUP: "Address_Look_up", +}; + +const ValueToLogTypes = { + LAST_5: "last_5", +}; + +export { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents, ValueToLogTypes }; diff --git a/src/constants/cookie-names.js b/src/constants/cookie-names.js new file mode 100644 index 00000000..5fab5289 --- /dev/null +++ b/src/constants/cookie-names.js @@ -0,0 +1,12 @@ +import { applicationConfig } from "@/constants/application-config.js"; + +const cookieNames = { + FUNNEL_SESSION_INFO: `FunnelSessionInfo-${applicationConfig.CURRENT_ENVIRONMENT}`, + + // Existing Safelite.com cookies + DXDEV: "dxdev", + SESSION_ID: "sid", + SESSION_KEY: "skey", +}; + +export { cookieNames }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js new file mode 100644 index 00000000..f2e5d492 --- /dev/null +++ b/src/constants/experiments.js @@ -0,0 +1,14 @@ +const experimentUniverses = { + CONCEPT_FUNNEL: "ConceptFunnel", +}; + +const experimentSettings = { + GOOGLE_CUSTOM_DIMENSION_INDEX: "Google Custom Dimension Index", +}; + +const experimentTriggers = { + SITE_ENTRY: "SiteEntry", + PAGE_ENTRY: "PageEntry", +}; + +export { experimentUniverses, experimentSettings, experimentTriggers }; diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js new file mode 100644 index 00000000..5b6800c8 --- /dev/null +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -0,0 +1,209 @@ +import { cookieNames } from "@/constants/cookie-names"; +import store from "@/store"; +import { applicationConfig } from "@/constants/application-config"; + +/* + Will update the cookie if present, or create a new one if not. +*/ +export function updateOrCreateFunnelCookie() { + const wasClaimRegistrationDelayed = getFunnelCookie()?.HasDelayedClaimRegistration; + const shouldSuppressConceptFunnel = getFunnelCookie()?.SuppressConceptFunnel; + + // Set up cookie with all the props. + setFunnelCookieProperties({ + LastTouched: new Date().toUTCString(), + SavedSessionTimeoutDate: store.getters.applicationUser.savedSessionTimeout, + DidHeritageFunnelUpdateLast: false, + ShouldResetState: false, + ReferralNumber: store.getters.order.referralNumber, + ReferralDate: store.getters.order.referralDate, + ReferralCorrelationId: store.getters.order.referralCorrelationId, + ReferralParentAccountNumber: store.getters.order.accountNumber, + HasDelayedClaimRegistration: wasClaimRegistrationDelayed, + SuppressConceptFunnel: shouldSuppressConceptFunnel, + }); +} + +/* + Gets the current instance of the funnel cookie. + Returns null if cookie isn't valid JSON. +*/ +export function getFunnelCookie() { + const cookieJson = document.cookie + ?.split("; ") + ?.find((row) => row.startsWith(`${cookieNames.FUNNEL_SESSION_INFO}=`)) + ?.split("=")[1]; + + try { + return JSON.parse(cookieJson); + } catch (error) { + return null; + } +} + +/* + Removes cookie from browser. +*/ +export function deleteFunnelCookie() { + createOrUpdateCookie(cookieNames.FUNNEL_SESSION_INFO, undefined, { maxAge: 0 }); +} + +/* + Gets cookie domain value. Localhost will be empty "". +*/ +export function getCookieDomainValue() { + return isLocalhost() ? "" : `domain=${getDomainWithoutSubdomain()};`; +} + +/* + Gets value of dxdev cookie, and then extracts "did" value from it. + Returns empty string if cookie not found or "did" string not present. +*/ +export function getDeviceIdValue() { + // Sometimes these cookie contains more than the device ID. + const cookieValue = getCookieValueByName(cookieNames.DXDEV); + const cookieValuesSplit = cookieValue.split("="); + + // If this is the only value, just use that. + if (cookieValuesSplit.length === 2 && cookieValuesSplit[0] === "did") { + return cookieValuesSplit[1]; + } + + const cookieValueMatch = cookieValue.match("^did=[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}"); + if (cookieValueMatch) { + return cookieValueMatch[0].split("=")[1]; + } + + return "00000000-0000-0000-0000-000000000000"; +} + +/* + Gets value of skey cookie, returns 0 if not found. +*/ +export function getSessionKeyValue() { + const cookieValue = getCookieValueByName(cookieNames.SESSION_KEY); + + if (cookieValue) { + return cookieValue; + } + + return 0; +} + +/* + Gets value of skey cookie, returns 0 if not found. +*/ +export function getSessionIdValue() { + const cookieValue = getCookieValueByName(cookieNames.SESSION_ID); + + if (cookieValue) { + return cookieValue; + } + + return "00000000-0000-0000-0000-000000000000"; +} + +/* + Updates session ID cookie with new expiration date +*/ +export function updateSessionIdCookie() { + createOrUpdateCookie(cookieNames.SESSION_ID, getSessionIdValue(), { maxAge: 60 * 30 }); +} + +export function setCookieProperties( + properties, + { useDefaultFunnelCookieAttributes = true, maxAge, isSecure } +) { + if (typeof properties == "object") { + Object.keys(properties).forEach((key) => { + createOrUpdateCookie(key, properties[key], { + useDefaultFunnelCookieAttributes, + maxAge, + isSecure, + }); + }); + } +} + +/* +=========================== += PRIVATE FUNCTIONS = +=========================== +*/ + +/* + Used to set properties on the funnel cookie. + Takes an object with properties to set. Will overwrite existing properties. +*/ +function setFunnelCookieProperties(properties) { + if (typeof properties == "object") { + let cookie = getFunnelCookie(); + + if (cookie !== null) { + Object.keys(properties).forEach((key) => { + cookie[key] = properties[key]; + }); + } + + const cookieValueJson = JSON.stringify(cookie ?? {}); + createOrUpdateCookie(cookieNames.FUNNEL_SESSION_INFO, cookieValueJson, {}); + } +} + +/* + Used to create a cookie. + `useDefaultFunnelCookieAttributes` will set the path and domain to our defaults +*/ +function createOrUpdateCookie( + key, + value = "", + { useDefaultFunnelCookieAttributes = true, maxAge, isSecure = true } +) { + let cookieToAdd = `${key}=${value}; `; + + if (useDefaultFunnelCookieAttributes) { + cookieToAdd += `path=${applicationConfig.COOKIE_PATH}; ${getCookieDomainValue()} `; + } + if (isSecure && !isLocalhost()) { + cookieToAdd += `secure; `; + } + if (!isNaN(maxAge)) { + cookieToAdd += `max-age=${maxAge};`; + } + + document.cookie = cookieToAdd; +} + +/* + Gets current domain without the subdomain for cookie. +*/ +function getDomainWithoutSubdomain() { + let url = location.hostname; + if (isLocalhost()) { + return "localhost"; + } + + const urlParts = url.split("."); + + return `.${urlParts + .slice(0) + .slice(-(urlParts.length === 4 ? 3 : 2)) + .join(".")}`; +} + +/* + Gets cookie value by name, returns empty string if not found. +*/ +function getCookieValueByName(name) { + const value = "; " + document.cookie; + const parts = value.split("; " + name + "="); + + if (parts.length === 2) { + return parts.pop().split(";").shift(); + } + return ""; +} + +function isLocalhost() { + return location.hostname.includes("localhost"); +} diff --git a/src/helpers/heritage-integration/cookie-helper.spec.js b/src/helpers/heritage-integration/cookie-helper.spec.js new file mode 100644 index 00000000..5df2ae92 --- /dev/null +++ b/src/helpers/heritage-integration/cookie-helper.spec.js @@ -0,0 +1,145 @@ +import { + getFunnelCookie, + getDeviceIdValue, + getSessionKeyValue, + getSessionIdValue, +} from "@/helpers/heritage-integration/cookie-helper.js"; +import { removeAllTestCookies, setupCookies } from "@/helpers/unit-test-helper"; + +describe("cookies", () => { + afterEach(() => { + removeAllTestCookies(); + }); + + describe("getFunnelCookie method", () => { + test("gets correct value when cookie is present", () => { + // Arrange + const testReferralNumber = 1566818; + const testReferralDate = "2022-03-15T10:56:24.597"; + const testReferralCorrelationId = "404d2b04-f86e-45c3-b373-127b6217b060"; + const testShouldResetState = false; + const testDidHeritageFunnelUpdateLast = true; + + const testCookieValue = { + ReferralNumber: testReferralNumber, + ReferralDate: testReferralDate, + ReferralCorrelationId: testReferralCorrelationId, + ShouldResetState: testShouldResetState, + DidHeritageFunnelUpdateLast: testDidHeritageFunnelUpdateLast, + SuppressConceptFunnel: true, + }; + + setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); + + // Act + var result = getFunnelCookie(); + + // Assert + expect(result).toEqual(testCookieValue); + expect(typeof result).toEqual("object"); + expect(result.ReferralNumber).toEqual(testReferralNumber); + expect(result.ReferralDate).toEqual(testReferralDate); + expect(result.ReferralCorrelationId).toEqual(testReferralCorrelationId); + expect(result.ShouldResetState).toEqual(testShouldResetState); + expect(result.DidHeritageFunnelUpdateLast).toEqual(testDidHeritageFunnelUpdateLast); + }); + + test("returns empty object when value is empty object", () => { + // Arrange + const testCookieValue = {}; + setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); + + // Act + var result = getFunnelCookie(); + + // Assert + expect(result).toEqual(testCookieValue); + expect(typeof result).toEqual("object"); + expect(Object.keys(result)).toHaveLength(0); + }); + + test("returns null when value is empty string", () => { + // Arrange + const testCookieValue = ""; + setupCookies({ funnelCookieValue: testCookieValue }); + + // Act + var result = getFunnelCookie(); + + // Assert + expect(result).toEqual(null); + }); + + test("returns null when funnel cookie doesn't exist", () => { + // Arrange + setupCookies({ includeHeritageCookie: false }); + + // Act + var result = getFunnelCookie(); + + // Assert + expect(result).toEqual(null); + }); + + test("gets correct cookie value", () => { + // Arrange + const testCookieValue = { test: "testValue" }; + + setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); + + // Act + const actualCookieValue = getFunnelCookie(); + + // Assert + expect(actualCookieValue).toEqual(testCookieValue); + }); + + test("getCookieValue: Gets null cookie value", () => { + // Arrange + setupCookies({ includeHeritageCookie: false }); + + // Act + const actualCookieValue = getFunnelCookie(); + + // Assert + expect(actualCookieValue).toBeNull(); + }); + }); + + describe("getDeviceIdValue", () => { + test("getDeviceIdValue, should return GUID", () => { + // Arrange + setupCookies({}); + + // Act + const result = getDeviceIdValue(); + + //Assert + expect(result).toBe("21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe"); + }); + + test("getSessionKeyValue, should return session key int", () => { + // Arrange + setupCookies({}); + + // Act + const result = getSessionKeyValue(); + + //Assert + expect(result).toBe("12345"); + }); + }); + + describe("getSessionIdValue", () => { + test("getSessionIdValue, should return GUID", () => { + // Arrange + setupCookies({}); + + // Act + const result = getSessionIdValue(); + + //Assert + expect(result).toBe("cba0c3d1-3c1b-4305-bb56-31aa50f58e27"); + }); + }); +}); diff --git a/src/helpers/heritage-integration/navigation-helper.js b/src/helpers/heritage-integration/navigation-helper.js new file mode 100644 index 00000000..e5133177 --- /dev/null +++ b/src/helpers/heritage-integration/navigation-helper.js @@ -0,0 +1,150 @@ +import { queryStrings } from "@/constants/query-strings"; +import { externalUrls } from "@/router/router-constants/externalUrl-values"; +import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js"; +import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; +import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; +import store from "@/store"; +import router from "@/router"; + +/* + If the user has visited the funnel before this method will determine the bets place to + drop them so they don't start at the beginning again. This method will return 'heritage' if + the user has an existing order and they come back in from the Safelite.com CTA. +*/ +export async function getPageToRouteExistingOrderTo(toRoute = {}, existingHeritageOrder = false) { + // If the user is coming in via the Safelite.Com CTA + if (toRoute.query[queryStrings.START_TYPE] === "fmg") { + // If they have an existing order, return 'heritage' for the page name. + if (existingHeritageOrder) { + return fmgPageValues.HERITAGE; + } + + return await getLatestPageForRedirection(); + } + + // If navigating to a specific page, and that page is not part of the vin pages. + // Return that page, so that it can navigate like normal. + if (toRoute.query[queryStrings.FMG_PAGE] !== undefined && !isVinRelatedPage(toRoute)) { + return overrideYmmsDirectionIfNeeded(toRoute); + } + + // If this is not a direct link to a page using fmgPage, not from Safelite.com CTA or this is a vin related page. + // Get the latest page for redirection. + const latestPageRoute = await getLatestPageForRedirection(); + + return latestPageRoute; +} + +/* + Used to navigate to the heritage funnel with the correct query string and url. +*/ + +export async function navigateToHeritageFunnel(shouldSaveSession = true) { + // Create the order (or save existing order) when navigating to Heritage Funnel. + if (shouldSaveSession) { + await saveSession(); + } + + router.navigateToExternalUrl(externalUrls.HERITAGE_FUNNEL, { + corid: store.getters.order.referralCorrelationId, + src: "concept-funnel", + conceptsqid: store.getters.applicationUser.savedSessionId, + }); +} + +/* + Logic for getting the last "valid" page a user visited. +*/ +async function getLatestPageForRedirection() { + // If this is a non-CTA navigation, determine where to send the user based on page prerequisites. + // This also works if a user has a 'fmg' start_type query string but no current order. + // That shouldn't happen, but it's possible. + const vehicleMakeComponent = await getLazyLoadedComponent(fmgPageValues.VEHICLE_MAKE); + const vehicleModelComponent = await getLazyLoadedComponent(fmgPageValues.VEHICLE_MODEL); + const vehicleStyleComponent = await getLazyLoadedComponent(fmgPageValues.VEHICLE_STYLE); + const vehicleDamageComponent = await getLazyLoadedComponent(fmgPageValues.VEHICLE_DAMAGE); + const estimateComponent = await getLazyLoadedComponent(fmgPageValues.ESTIMATE); + const vinLookupComponent = await getLazyLoadedComponent(fmgPageValues.VIN_LOOKUP); + const partQuestionsComponent = await getLazyLoadedComponent(fmgPageValues.PART_QUESTIONS); + const vehiclePartsComponent = await getLazyLoadedComponent(fmgPageValues.VEHICLE_PARTS); + const moldingQuestionsComponent = await getLazyLoadedComponent(fmgPageValues.MOLDING_QUESTIONS); + const capabilityQuestionsComponent = await getLazyLoadedComponent( + fmgPageValues.CAPABILITY_QUESTIONS + ); + + if (!vehicleMakeComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_YEAR; + } else if (!vehicleModelComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_MAKE; + } else if (!vehicleStyleComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_MODEL; + } else if (!vehicleDamageComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_STYLE; + } else if (!estimateComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_DAMAGE; + } else { + if (capabilityQuestionsComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.CAPABILITY_QUESTIONS; + } else if (moldingQuestionsComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.MOLDING_QUESTIONS; + } else if (vehiclePartsComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_PARTS; + } else if (partQuestionsComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.PART_QUESTIONS; + } else if ( + vinLookupComponent.methods.arePagePrerequisitesValid() && + !store.getters.damage.isRepair + ) { + return fmgPageValues.VIN_LOOKUP; + } else { + return fmgPageValues.ESTIMATE; + } + } +} + +/* + Overrides functionality to go to the YMMS pages in certain cases. + If this is not one of the cases, it returns the 'to' fmgPage value. +*/ + +/* istanbul ignore next */ +function overrideYmmsDirectionIfNeeded(toRoute) { + const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE]; + + if (store.getters.payment.insuranceCoverage.isVerified) { + switch (fmgPageValue) { + case fmgPageValues.VEHICLE_YEAR: + case fmgPageValues.VEHICLE_MAKE: + case fmgPageValues.VEHICLE_MODEL: + case fmgPageValues.VEHICLE_STYLE: { + return fmgPageValues.VEHICLE_DAMAGE; + } + default: { + return fmgPageValue; + } + } + } else { + return fmgPageValue; + } +} + +/* + Determine if the page is a vin related page. +*/ + +/* istanbul ignore next */ +function isVinRelatedPage(toRoute) { + const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE]; + + return ( + fmgPageValue === fmgPageValues.VIN_LOOKUP || + fmgPageValue === fmgPageValues.LICENSE_PLATE_LOOKUP || + fmgPageValue === fmgPageValues.ADDRESS_LOOKUP || + fmgPageValue === fmgPageValues.ADDRESS_VEHICLES || + fmgPageValue === fmgPageValues.ESTIMATE + ); +} + +async function getLazyLoadedComponent(pageName) { + return (await lazyLoadComponent(pageName)()).default; +} diff --git a/src/helpers/heritage-integration/navigation-helper.spec.js b/src/helpers/heritage-integration/navigation-helper.spec.js new file mode 100644 index 00000000..7e26f4a5 --- /dev/null +++ b/src/helpers/heritage-integration/navigation-helper.spec.js @@ -0,0 +1,445 @@ +import { + getPageToRouteExistingOrderTo, + navigateToHeritageFunnel, +} from "@/helpers/heritage-integration/navigation-helper"; +import * as orderHelper from "@/helpers/heritage-integration/order-helper"; +import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js"; +import { storeActions } from "@/constants/store-actions"; +import { setupMocksForJsFiles, getMockOrderInfo } from "@/helpers/unit-test-helper.js"; +import { externalUrls } from "@/router/router-constants/externalUrl-values"; +import { queryStrings } from "@/constants/query-strings"; +import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; + +import store from "@/store"; +import router from "@/router"; + +// Mock Lazy Load +jest.mock("@/router/dynamic-routing/component-loader.js", () => ({ + lazyLoadComponent: jest.fn(), +})); + +describe("getPageToRouteExistingOrderTo", () => { + test("should return vehicle-year", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_YEAR); + }); + + test("should return vehicle-make", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_MAKE); + }); + + test("should return vehicle-model", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_MODEL); + }); + + test("should return vehicle-style", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_STYLE); + }); + + test("should return vehicle-damage", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_DAMAGE); + }); + + test("user has YMMS and no vehicle questions > should return vin-lookup", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: false, + [fmgPageValues.MOLDING_QUESTIONS]: false, + [fmgPageValues.VEHICLE_PARTS]: false, + [fmgPageValues.PART_QUESTIONS]: false, + [fmgPageValues.VIN_LOOKUP]: true, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VIN_LOOKUP); + }); + + test("user has YMMS but no questions or carId > should return estimate", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: false, + [fmgPageValues.MOLDING_QUESTIONS]: false, + [fmgPageValues.VEHICLE_PARTS]: false, + [fmgPageValues.PART_QUESTIONS]: false, + [fmgPageValues.VIN_LOOKUP]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.ESTIMATE); + }); + + test("user has capability questions and molding questions > should return capability questions", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: true, + [fmgPageValues.MOLDING_QUESTIONS]: true, + [fmgPageValues.VEHICLE_PARTS]: false, + [fmgPageValues.PART_QUESTIONS]: false, + [fmgPageValues.VIN_LOOKUP]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.CAPABILITY_QUESTIONS); + }); + + test("user has molding questions and part questions > should return molding questions", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: false, + [fmgPageValues.MOLDING_QUESTIONS]: true, + [fmgPageValues.VEHICLE_PARTS]: false, + [fmgPageValues.PART_QUESTIONS]: true, + [fmgPageValues.VIN_LOOKUP]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.MOLDING_QUESTIONS); + }); + + test("user has vehicle parts questions > should return vehicle-parts", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: false, + [fmgPageValues.MOLDING_QUESTIONS]: false, + [fmgPageValues.VEHICLE_PARTS]: true, + [fmgPageValues.PART_QUESTIONS]: true, + [fmgPageValues.VIN_LOOKUP]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.VEHICLE_PARTS); + }); + + test("user has part questions > should return part-questions", async () => { + // Arrange + const toRoute = { + query: {}, + }; + + // Mock out the lazy load calls for all components. + mockLazyLoadComponentReturnValues({ + [fmgPageValues.VEHICLE_MAKE]: true, + [fmgPageValues.VEHICLE_MODEL]: true, + [fmgPageValues.VEHICLE_STYLE]: true, + [fmgPageValues.VEHICLE_DAMAGE]: true, + [fmgPageValues.ESTIMATE]: true, + [fmgPageValues.CAPABILITY_QUESTIONS]: false, + [fmgPageValues.MOLDING_QUESTIONS]: false, + [fmgPageValues.VEHICLE_PARTS]: false, + [fmgPageValues.PART_QUESTIONS]: true, + [fmgPageValues.VIN_LOOKUP]: false, + }); + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, false); + + //Assert + expect(result).toBe(fmgPageValues.PART_QUESTIONS); + }); + + test("existing order > should return heritage", async () => { + // Arrange + const toRoute = { + query: { + [queryStrings.START_TYPE]: "fmg", + }, + }; + + // Act + const result = await getPageToRouteExistingOrderTo(toRoute, true); + + // Assert + expect(result).toBe(fmgPageValues.HERITAGE); + }); +}); + +describe("navigateToHeritageFunnel", () => { + test("should save session", async () => { + // Arrange + const mockReferralNumber = "2"; + const mockCorrelationId = "55"; + const mockReferralDate = "2022"; + const mockAccountNumber = "167132"; + const mockSavedSessionId = "xxx-xxx-xxx"; + const mockCrmCustomerId = "xxx-xxx-xxx"; + + const mockOrderInfo = getMockOrderInfo( + mockReferralNumber, + mockCorrelationId, + mockReferralDate, + mockAccountNumber, + mockSavedSessionId, + mockCrmCustomerId + ); + + const mockData = { + actionList: [ + { + actionName: storeActions.SAVE_SESSION, + data: mockOrderInfo, + }, + ], + }; + + setupMocksForJsFiles(mockData); + const saveSessionFunction = jest.spyOn(orderHelper, "saveSession"); + router.navigateToExternalUrl = jest.fn(); + + // Act + await navigateToHeritageFunnel(); + + // Assert + expect(saveSessionFunction).toHaveBeenCalled(); + + // Should save before we navigate to heritage by default + const saveSessionFunctionCallOrder = saveSessionFunction.mock.invocationCallOrder[0]; + const routerNavigateFunctionCallOrder = + router.navigateToExternalUrl.mock.invocationCallOrder[0]; + expect(saveSessionFunctionCallOrder).toBeLessThan(routerNavigateFunctionCallOrder); + saveSessionFunction.mockRestore(); + }); + + test("should go to heritage funnel", async () => { + // Arrange + const mockReferralNumber = "2"; + const mockCorrelationId = "55"; + const mockReferralDate = "2022"; + const mockAccountNumber = "167132"; + const mockSavedSessionId = "xxx-xxx-xxx"; + const mockCrmCustomerId = "xxx-xxx-xxx"; + + const mockOrderInfo = getMockOrderInfo( + mockReferralNumber, + mockCorrelationId, + mockReferralDate, + mockAccountNumber, + mockSavedSessionId, + mockCrmCustomerId + ); + + const mockData = { + actionList: [ + { + actionName: storeActions.SAVE_SESSION, + data: mockOrderInfo, + }, + ], + }; + + setupMocksForJsFiles(mockData); + + store.getters.order.referralCorrelationId = mockCorrelationId; + + router.navigateToExternalUrl = jest.fn(); + + // Act + await navigateToHeritageFunnel(); + + // Assert + expect(router.navigateToExternalUrl).toHaveBeenCalled(); + expect(router.navigateToExternalUrl).toHaveBeenCalledWith( + externalUrls.HERITAGE_FUNNEL, + expect.objectContaining({ + corid: mockCorrelationId, + }) + ); + }); + + test("should not save session, but should still navigate", async () => { + // Arrange + const mockReferralNumber = "2"; + const mockCorrelationId = "55"; + const mockReferralDate = "2022"; + + const mockOrderInfo = getMockOrderInfo( + mockReferralNumber, + mockCorrelationId, + mockReferralDate + ); + + const mockData = { + actionList: [ + { + actionName: storeActions.SAVE_SESSION, + data: mockOrderInfo, + }, + ], + }; + + setupMocksForJsFiles(mockData); + const saveSessionFunction = jest.spyOn(orderHelper, "saveSession"); + router.navigateToExternalUrl = jest.fn(); + + // Act + await navigateToHeritageFunnel(false); + + // Assert + expect(saveSessionFunction).not.toHaveBeenCalled(); + expect(router.navigateToExternalUrl).toHaveBeenCalled(); + saveSessionFunction.mockRestore(); + }); +}); + +/** + * `arePagePrerequisitesValidObject` is an object where the keys are fmgPageValue names and the values are booleans that indicate + * whether arePagePrerequisitesValid is true or false + */ +function mockLazyLoadComponentReturnValues(arePagePrerequisitesValidObject = {}) { + lazyLoadComponent.mockImplementation((pageName) => { + return async () => { + return Promise.resolve({ + default: { + methods: { + arePagePrerequisitesValid: jest + .fn() + .mockReturnValueOnce(arePagePrerequisitesValidObject[pageName]), + }, + }, + }); + }; + }); +} diff --git a/src/helpers/heritage-integration/order-helper.js b/src/helpers/heritage-integration/order-helper.js new file mode 100644 index 00000000..db95e070 --- /dev/null +++ b/src/helpers/heritage-integration/order-helper.js @@ -0,0 +1,113 @@ +import { storeActions } from "@/constants/store-actions.js"; +import { + getFunnelCookie, + updateOrCreateFunnelCookie, + deleteFunnelCookie, +} from "@/helpers/heritage-integration/cookie-helper.js"; +import baseMixin from "@/mixins/base-mixin"; +import store from "@/store"; +import { storeMutations } from "@/constants/store-mutations"; + +/* + Will call API and hydrate state with data from API if present. If there is no order present + method will return null. If the cookie dictates the state should be reset + it will reset the state and go back to the start of the funnel. + +*/ +export async function loadSessionIfPresent() { + const funnelCookie = getFunnelCookie(); + + // Do nothing if there is no cookie, correlation id, or referral number. + if ( + funnelCookie == null || + funnelCookie.ReferralCorrelationId == null || + !funnelCookie.ReferralNumber + ) { + return null; + } + + // Reset state if cookie says to. + if (funnelCookie.ShouldResetState) { + baseMixin.methods.dispatchStoreAction(storeActions.RESET_STATE); + deleteFunnelCookie(); + return null; + } + + // Load referral if there is a cookie, and it doesn't indicate it needs a state reset. + return ( + await loadSession( + funnelCookie.ReferralNumber, + funnelCookie.ReferralDate, + funnelCookie.ReferralCorrelationId, + funnelCookie.ReferralParentAccountNumber + ) + ).data; +} + +/* + Will call API to save existing order, or create new one depending where it's called from. + This will also set Referral information in the store after saving, and then + update the cookie. +*/ +export async function saveSession() { + var saveSessionPromise; + if (store.getters.applicationUser.saveSessionPromise) { + // queue newest request after current saveSessionPromise resolves + saveSessionPromise = store.getters.applicationUser.saveSessionPromise.then(() => { + // get a new saveSessionPromise + return saveSessionHelper(); + }); + } else { + // create an initial saveSessionPromise + saveSessionPromise = saveSessionHelper(); + } + store.commit(storeMutations.UPDATE_SAVE_SESSION_PROMISE, saveSessionPromise); + // await here to allow for a caller to await and make the function synchronous + await saveSessionPromise; +} + +// PRIVATE FUNCTIONS // + +/* + Calls API to load session given the referral number, referralDate, and referralCorrelationId + and returns the response. +*/ +async function loadSession(referralNumber, referralDate, referralCorrelationId, accountNumber) { + // await the saveSessionPromise in the store to make sure we're loading up to date information + await store.getters.applicationUser.saveSessionPromise; + const response = await baseMixin.methods.dispatchStoreAction( + storeActions.LOAD_SESSION, + { + referralNumber: referralNumber.toString(), + referralDate: referralDate, + referralCorrelationId: referralCorrelationId, + accountNumber: accountNumber?.toString(), + }, + false + ); + + return response; +} + +/* + Encapsulates asynchronous Save Session logic inside a promise to allow for Save Session queuing +*/ +async function saveSessionHelper() { + const savedSessionInfo = await baseMixin.methods.dispatchStoreAction(storeActions.SAVE_SESSION); + // Update the store with information received from the saveSession response + await baseMixin.methods.dispatchStoreAction( + storeActions.UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE, + { + referralNumber: savedSessionInfo.data.referralNumber.toString(), + referralCorrelationId: savedSessionInfo.data.referralCorrelationId, + referralDate: savedSessionInfo.data.referralDate, + accountNumber: savedSessionInfo.data.accountNumber.toString(), + savedSessionId: savedSessionInfo.data.savedSessionId, + crmCustomerId: savedSessionInfo.data.crmCustomerId.toString(), + }, + false + ); + + // Update the cookie with the referral information when saved. + updateOrCreateFunnelCookie(); +} diff --git a/src/helpers/heritage-integration/order-helper.spec.js b/src/helpers/heritage-integration/order-helper.spec.js new file mode 100644 index 00000000..5ec0f4f6 --- /dev/null +++ b/src/helpers/heritage-integration/order-helper.spec.js @@ -0,0 +1,236 @@ +import * as cookieHelper from "@/helpers/heritage-integration/cookie-helper"; +import { loadSessionIfPresent, saveSession } from "@/helpers/heritage-integration/order-helper"; +import { cookieNames } from "@/constants/cookie-names"; +import { + setupMocksForJsFiles, + removeAllTestCookies, + getMockOrderInfo, + setupCookies, +} from "@/helpers/unit-test-helper.js"; +import { storeActions } from "@/constants/store-actions"; +import router from "@/router"; + +describe("loadSessionIfPresent", () => { + afterEach(() => { + removeAllTestCookies(); + }); + + test("ShouldResetState == true => funnel cookie is deleted", () => { + // Arrange + cookieHelper.deleteFunnelCookie = jest.spyOn(cookieHelper, "deleteFunnelCookie"); + const testShouldResetState = true; + + const testCookieValue = { + ShouldResetState: testShouldResetState, + ReferralCorrelationId: "xxx", + ReferralNumber: "12345", + }; + + document.cookie = `${cookieNames.FUNNEL_SESSION_INFO}=${JSON.stringify( + testCookieValue + )}; path=/; ${cookieHelper.getCookieDomainValue()}`; + + // Act + loadSessionIfPresent(); + + // Assert + expect(cookieHelper.deleteFunnelCookie).toHaveBeenCalled(); + expect(document.cookie).toBe(""); + }); + + test("ShouldResetState == true => reset store", () => { + // Arrange + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValueOnce({ + ShouldResetState: true, + ReferralCorrelationId: "xxx-xxx-xxx", + ReferralNumber: "12345", + }); + + const mockData = { + actionList: [ + { + actionName: storeActions.RESET_STATE, + }, + ], + }; + + var mocks = setupMocksForJsFiles(mockData); + + // Act + loadSessionIfPresent(); + + // Assert + expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith( + storeActions.RESET_STATE + ); + }); + + test("Funnel cookie is null => store is unchanged", () => { + // Arrange + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValueOnce(null); + + const mockData = { + actionList: [ + { + actionName: storeActions.RESET_STATE, + }, + ], + }; + + var mocks = setupMocksForJsFiles(mockData); + + // Act + loadSessionIfPresent(); + + // Assert + expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); + expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.RESET_STATE + ); + }); + + test("Funnel cookie valid, should call loadSession", async () => { + // Arrange + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValueOnce({ + ShouldResetState: false, + ReferralNumber: 123456, + ReferralCorrelationId: "yyy-yyy-yyyy", + ReferralDate: new Date(), + }); + + const mockData = { + actionList: [ + { + actionName: storeActions.LOAD_SESSION, + data: { ReferralNumber: 123456, vehicle: { year: 2010 } }, + }, + ], + }; + + var mocks = setupMocksForJsFiles(mockData); + + // Act + const result = await loadSessionIfPresent(); + + // Assert + expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); + expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.LOAD_SESSION + ); + expect(result.ReferralNumber).toBe(123456); + expect(result.vehicle.year).toBe(2010); + }); +}); + +describe("saveSession", () => { + afterEach(() => { + removeAllTestCookies(); + }); + + test("saveSession => should set state order values", async () => { + // Arrange + const mockReferralNumber = "2"; + const mockCorrelationId = "55"; + const mockReferralDate = "2022"; + const mockAccountNumber = "167132"; + const mockSavedSessionId = "xxx-xxx-xxx"; + const mockCrmCustomerId = "xxx-xxx-xxx"; + + const mockOrderInfo = getMockOrderInfo( + mockReferralNumber, + mockCorrelationId, + mockReferralDate, + mockAccountNumber, + mockSavedSessionId, + mockCrmCustomerId + ); + + const mockData = { + actionList: [ + { + actionName: storeActions.SAVE_SESSION, + data: mockOrderInfo, + }, + { + actionName: storeActions.UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE, + }, + ], + }; + + const mocks = setupMocksForJsFiles(mockData); + + // Act + await saveSession(); + + // Assert + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_SESSION + ); + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith( + storeActions.UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE, + { + referralNumber: mockReferralNumber, + referralDate: mockReferralDate, + referralCorrelationId: mockCorrelationId, + accountNumber: mockAccountNumber, + savedSessionId: mockSavedSessionId, + crmCustomerId: mockCrmCustomerId, + }, + false + ); + }); + + test("saveSession => should update DidHeritageFunnelUpdateLast cookie value to false", async () => { + // Arrange + const mockReferralNumber = 1566818; + const mockReferralDate = "2022-03-15T10:56:24.597"; + const mockReferralCorrelationId = "404d2b04-f86e-45c3-b373-127b6217b060"; + const mockAccountNumber = "167132"; + const mockSavedSessionId = "xxx-xxx-xxx"; + const mockCrmCustomerId = "xxx-xxx-xxx"; + + const mockOrderInfo = getMockOrderInfo( + mockReferralNumber, + mockReferralCorrelationId, + mockReferralDate, + mockAccountNumber, + mockSavedSessionId, + mockCrmCustomerId + ); + + const mockData = { + actionList: [ + { + actionName: storeActions.SAVE_SESSION, + data: mockOrderInfo, + }, + ], + router: router, + }; + + setupMocksForJsFiles(mockData); + + const testCookieValue = { + ReferralNumber: mockReferralNumber, + ReferralDate: mockReferralDate, + ReferralCorrelationId: mockReferralCorrelationId, + ShouldResetState: false, + DidHeritageFunnelUpdateLast: true, + }; + + setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); + + // Act + await saveSession(); + + // Assert + expect(cookieHelper.getFunnelCookie().DidHeritageFunnelUpdateLast).toEqual(false); + }); +}); diff --git a/src/helpers/heritage-integration/session-helper.js b/src/helpers/heritage-integration/session-helper.js new file mode 100644 index 00000000..291ebfaf --- /dev/null +++ b/src/helpers/heritage-integration/session-helper.js @@ -0,0 +1,47 @@ +import { applicationConfig } from "@/constants/application-config"; +import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper.js"; + +/* + Method to determine if our analytics session has timed out or not. + Amount used for timeout is configurable in application-config.js +*/ +export function isAnalyticsSessionStillActive() { + if (getFunnelCookie() !== null) { + const lastTouchedValue = getFunnelCookie().LastTouched; + const timeoutAmount = applicationConfig.ANALYTICS_SESSION_TIMEOUT_MINUTES; + const isMoreThanHalfHourAgo = + (new Date() - new Date(lastTouchedValue)) / 60000 > timeoutAmount; + + if (isMoreThanHalfHourAgo) { + return false; + } + + return true; + } +} + +/* + Method to determine if the users 'saved' session is still active. + When user state is created, there is a date that is saved into state + this method checks against that date. + + Note: That time for saved session timeout is configurable in application-config.js +*/ +export function isSavedSessionStillActive() { + if (getFunnelCookie() !== null) { + const savedSessionTimeStamp = new Date(getFunnelCookie().SavedSessionTimeoutDate); + const isSavedSessionTimedOut = new Date(new Date().toUTCString()) > savedSessionTimeStamp; + + return !isSavedSessionTimedOut; + } +} + +/* +Function to calculate the date for the saved session timeout. +*/ + +export function getDateForSavedSessionTimeout() { + const currentDate = new Date(new Date().toUTCString()); + currentDate.setDate(currentDate.getDate() + applicationConfig.SAVED_SESSION_TIMEOUT_DAYS); + return currentDate.toUTCString(); +} diff --git a/src/helpers/heritage-integration/session-helper.spec.js b/src/helpers/heritage-integration/session-helper.spec.js new file mode 100644 index 00000000..fe3e308d --- /dev/null +++ b/src/helpers/heritage-integration/session-helper.spec.js @@ -0,0 +1,89 @@ +import * as cookieHelper from "@/helpers/heritage-integration/cookie-helper"; +import { + isAnalyticsSessionStillActive, + isSavedSessionStillActive, + getDateForSavedSessionTimeout, +} from "@/helpers/heritage-integration/session-helper"; +import { applicationConfig } from "@/constants/application-config"; + +describe("isAnalyticsSessionStillActive", () => { + test("isAnalyticsSessionStillActive, should return true", () => { + // Arrange + const mockDate = new Date(new Date().toUTCString()); + mockDate.setDate(mockDate.getDate() + 1); + + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValue({ LastTouched: mockDate }); + + // Act + const result = isAnalyticsSessionStillActive(); + + // Assert + expect(result).toBe(true); + }); + + test("isAnalyticsSessionStillActive, should return false", () => { + // Arrange + const mockDate = new Date(new Date().toUTCString()); + mockDate.setDate(mockDate.getDate() - 1); + + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValue({ LastTouched: mockDate }); + + // Act + const result = isAnalyticsSessionStillActive(); + + // Assert + expect(result).toBe(false); + }); +}); + +describe("isSavedSessionStillActive", () => { + test("isSavedSessionStillActive, should return true", () => { + // Arrange + const mockDate = new Date(new Date().toUTCString()); + mockDate.setDate(mockDate.getDate() + 1); + + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValue({ SavedSessionTimeoutDate: mockDate }); + + // Act + const result = isSavedSessionStillActive(); + + // Assert + expect(result).toBe(true); + }); + + test("isSavedSessionStillActive, should return false", () => { + // Arrange + const mockDate = new Date(new Date().toUTCString()); + mockDate.setDate(mockDate.getDate() - 1); + + cookieHelper.getFunnelCookie = jest + .spyOn(cookieHelper, "getFunnelCookie") + .mockReturnValue({ SavedSessionTimeoutDate: mockDate }); + + // Act + const result = isSavedSessionStillActive(); + + // Assert + expect(result).toBe(false); + }); +}); + +describe("getDateForSavedSessionTimeout", () => { + test("getDateForSavedSessionTimeout, should equal application config setting", () => { + // Arrange + const currentDate = new Date(new Date().toUTCString()); + currentDate.setDate(currentDate.getDate() + applicationConfig.SAVED_SESSION_TIMEOUT_DAYS); + + // Act + const result = getDateForSavedSessionTimeout(); + + // Assert + expect(result).toEqual(currentDate.toUTCString()); + }); +}); diff --git a/src/helpers/unit-test-helper.js b/src/helpers/unit-test-helper.js new file mode 100644 index 00000000..c54bbf01 --- /dev/null +++ b/src/helpers/unit-test-helper.js @@ -0,0 +1,144 @@ +import { storeActions } from "@/constants/store-actions"; +import { storeMutations } from "@/constants/store-mutations.js"; +import { navigationScenarios } from "@/router/router-constants/navigation-scenarios.js"; +import { vehicleCategories } from "@/constants/vehicle-categories.js"; +import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; +import { cookieNames } from "@/constants/cookie-names"; +import { Form } from "vee-validate"; +import baseMixin from "@/mixins/base-mixin"; +import { + getCookieDomainValue, + setCookieProperties, +} from "@/helpers/heritage-integration/cookie-helper"; +import { + analyticsPageEvents, + GaCategories, + GaActions, + GaLabels, + GaEvents, + ValueToLogTypes, +} from "@/constants/analytics"; +import { queryStrings } from "@/constants/query-strings"; +import { routerParams } from "@/router/router-constants/router-params"; + +// Common methods +export function getMountOptions(mockData) { + // Define our mocks to attached to the 'global' object for Vue/Jest. + const mocks = {}; + + //this is mocking if you use the mixin directly(baseMixin.methods.dispatchStoreAction) vs this.dispatchStoreAction + setupBaseMixinDispatchStoreAction(mockData); + + mocks.pushEventToGA = jest.fn(); + mocks.pushPageViewToGA = jest.fn(); + mocks.logEvent = jest.fn(); + mocks.pushExperimentsToDataLayer = jest.fn(); + mocks.prependActionToMethod = jest.fn(); + mocks.dispatchStoreAction = jest.fn(); + mocks.dispatchStoreAction.mockImplementation((actionName) => { + let actionFilterResult = mockData.actionList?.filter((x) => x.actionName == actionName); + + if (actionFilterResult?.length === 1) { + return Promise.resolve({ + data: actionFilterResult[0].data, + }); + } + }); + + // Mock const files + mocks.storeActions = storeActions; + mocks.storeMutations = storeMutations; + mocks.navigationScenarios = navigationScenarios; + mocks.vehicleCategories = vehicleCategories; + mocks.fmgPageValues = fmgPageValues; + mocks.analyticsPageEvents = analyticsPageEvents; + mocks.GaCategories = GaCategories; + mocks.GaActions = GaActions; + mocks.GaLabels = GaLabels; + mocks.GaEvents = GaEvents; + mocks.ValueToLogTypes = ValueToLogTypes; + mocks.queryStrings = queryStrings; + mocks.routerParams = routerParams; + + // Mock $store and $router when accessing this.$store/$router + mocks.$store = mockData.store; + mocks.$router = mockData.router; + mocks.$route = mockData.route; + mocks.$loadScript = mockData.loadScript; + + const global = { + mocks: mocks, + mixins: mockData.mixins, + stubs: { Form }, + }; + + return { global }; +} + +export function setupMocksForJsFiles(mockData = {}) { + setupBaseMixinDispatchStoreAction(mockData); + + return { baseMixin }; +} + +// Heritage integration common methods +export const cookies = { + [cookieNames.FUNNEL_SESSION_INFO]: `{"ReferralNumber":"1566818","ReferralDate":"2022-03-15T10:56:24.597","ReferralCorrelationId":"404d2b04-f86e-45c3-b373-127b6217b060","ShouldResetState":false,"DidHeritageFunnelUpdateLast":true}`, + UNIQUE_SESSION_ID: "33756020-b58e-4ec7-b8b8-3f1576719c40", + anotherCookie: "{}", + someOtherCookie: "{}", + dxdev: "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe", + sid: "cba0c3d1-3c1b-4305-bb56-31aa50f58e27", + skey: "12345", +}; + +// Removes test cookies for testing cookie-helper and order-helper +export function removeAllTestCookies() { + Object.keys(cookies).forEach((key) => { + document.cookie = `${key}=;Max-Age=0;`; + document.cookie = `${key}=;Max-Age=0;path=/;${getCookieDomainValue()}`; + }); +} + +export function getMockOrderInfo( + mockReferralNumber, + mockCorrelationId, + mockReferralDate, + accountNumber = "0", + savedSessionId, + crmCustomerId +) { + return { + referralNumber: mockReferralNumber, + referralCorrelationId: mockCorrelationId, + referralDate: mockReferralDate, + accountNumber: accountNumber, + savedSessionId: savedSessionId, + crmCustomerId: crmCustomerId, + }; +} + +export function setupCookies({ funnelCookieValue = "", includeHeritageCookie = true }) { + Object.keys(cookies).forEach((key) => { + const cookieValue = + key == cookieNames.FUNNEL_SESSION_INFO ? funnelCookieValue : cookies[key]; + if (includeHeritageCookie || key != cookieNames.FUNNEL_SESSION_INFO) + setCookieProperties({ [key]: cookieValue }, { isSecure: false }); + }); +} + +// Private methods +function setupBaseMixinDispatchStoreAction(mockData) { + if (mockData.actionList !== undefined) { + baseMixin.methods.dispatchStoreAction = jest.fn(); + baseMixin.methods.dispatchStoreAction.mockImplementation((actionName) => { + let actionFilterResult = mockData.actionList.filter((x) => x.actionName == actionName); + + if (actionFilterResult.length > 0 && actionFilterResult.length === 1) { + return Promise.resolve({ + data: actionFilterResult[0].data, + }); + } + }); + } +} diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js new file mode 100644 index 00000000..a4eb993d --- /dev/null +++ b/src/mixins/analytics-mixin.js @@ -0,0 +1,212 @@ +import { storeActions } from "@/constants/store-actions"; +import { + setCookieProperties, + getDeviceIdValue, + getSessionIdValue, + getSessionKeyValue, +} from "@/helpers/heritage-integration/cookie-helper"; +import { queryStrings } from "@/constants/query-strings"; +import { experimentSettings } from "@/constants/experiments"; +import { + analyticsPageEvents, + GaCategories, + GaActions, + GaLabels, + GaEvents, + ValueToLogTypes, +} from "@/constants/analytics"; +import { cookieNames } from "@/constants/cookie-names"; +import store from "@/store"; + +import baseMixin from "@/mixins/base-mixin"; + +export default { + methods: { + logPageView(pageEvent) { + const currentPageName = getPageNameByQueryString(); + var payload = { + userId: getDeviceIdValue(), + sessionKey: getSessionKeyValue(), + pageName: currentPageName, + sessionId: getSessionIdValue(), + action: "", + event: pageEvent, + shouldUseSessionId: false, + experimentsForUser: store.getters.applicationUser.experiments, + }; + + baseMixin.methods.dispatchStoreAction(storeActions.LOG_PAGE_VIEW, payload, false); + }, + + logCustomEvent(category, action, label, value) { + const currentPageName = getPageNameByQueryString(); + + var payload = { + userId: getDeviceIdValue(), + sessionKey: getSessionKeyValue(), + pageName: currentPageName, + sessionId: getSessionIdValue(), + category: category, + action: action, + label: label, + value: value, + shouldUseSessionId: false, + experimentsForUser: store.getters.applicationUser.experiments, + }; + + baseMixin.methods.dispatchStoreAction(storeActions.LOG_CUSTOM_EVENT, payload, false); + }, + + pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) { + const currentPageName = getPageNameByQueryString(); + const labelToLog = getValueToLog(label, valueToLogType); + + const eventToBePushed = { + event: GaEvents.GENERIC_EVENT, + category: category, + action: action, + label: labelToLog, + value: undefined, + path: `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}`, + }; + + pushToDataLayerIfDefined(eventToBePushed); + + if (pushToLogApp) { + this.logCustomEvent(category, action, labelToLog, undefined); + } + }, + + pushPageViewToGA() { + const currentPageName = getPageNameByQueryString(); + const pageViewEvent = { + event: GaEvents.PAGE_VIEW_EVENT, + pagePath: `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}`, + pageTitle: currentPageName, + }; + + pushToDataLayerIfDefined(pageViewEvent); + + this.logPageView(analyticsPageEvents.ENTRY); + }, + + pushExperimentsToDataLayer() { + const experiments = store.getters.applicationUser.experiments; + experiments?.forEach((exp) => { + // Set Google Dimension Index based on experiment settings. + let googleDimensionIndex = 99; + + if (exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX] !== undefined) { + googleDimensionIndex = + exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX]; + } + + // Create object with dimension index and value. + const experimentWithDimension = { + [`experimentId_${googleDimensionIndex}`]: exp.universeId, + [`variationId_${googleDimensionIndex}`]: exp.variationId, + [`experimentName_${googleDimensionIndex}`]: exp.universeName, + [`variationName_${googleDimensionIndex}`]: exp.variationName, + [`customDimension_${googleDimensionIndex}`]: `${exp.universeId}_${exp.variationId}_${exp.universeName}_${exp.variationName}`, + }; + + // Push to the data layer with the Google Custom Dimension Index. + pushToDataLayerIfDefined(experimentWithDimension); + }); + }, + + prependActionToMethod(object, method, actionToPrepend) { + const baseMethodName = method.name.startsWith("bound ") + ? method.name.substring(6) + : method.name; + const baseMethod = object[baseMethodName]; + object[baseMethodName] = function () { + actionToPrepend.apply(this, arguments); + return baseMethod.apply(object, arguments); + }; + }, + + async initSession() { + const sid = getSessionIdValue(); + const skey = getSessionKeyValue(); + var payload = { + userId: getDeviceIdValue(), + sessionId: sid, + userAgent: navigator.userAgent, + referrer: document.referrer, + }; + + const response = await baseMixin.methods.dispatchStoreAction( + storeActions.INITIALIZE_SESSION, + payload, + false + ); + + if (response.data) { + if (response.data.sessionKey && skey === 0) { + setCookieProperties( + { [cookieNames.SESSION_KEY]: response.data.sessionKey }, + { + useDefaultFunnelCookieAttributes: false, + } + ); + } + if (response.data.sessionId && sid === "00000000-0000-0000-0000-000000000000") { + setCookieProperties( + { [cookieNames.SESSION_ID]: response.data.sessionId }, + { + maxAge: 60 * 30, // 30 minutes + } + ); + } + } + }, + + noSession() { + return ( + getSessionKeyValue() === 0 || + getSessionIdValue() === "00000000-0000-0000-0000-000000000000" + ); + }, + }, + computed: { + analyticsPageEvents() { + return analyticsPageEvents; + }, + GaCategories() { + return GaCategories; + }, + GaActions() { + return GaActions; + }, + GaLabels() { + return GaLabels; + }, + ValueToLogTypes() { + return ValueToLogTypes; + }, + }, +}; + +function pushToDataLayerIfDefined(data) { + if (window.dataLayer !== undefined) { + window.dataLayer.push(data); + } +} + +function getPageNameByQueryString() { + const params = new URLSearchParams(location.search); + + if (params.has(queryStrings.FMG_PAGE)) { + return params.get(queryStrings.FMG_PAGE); + } else { + return ""; + } +} + +function getValueToLog(value, valueToLogType) { + if (valueToLogType != null && valueToLogType === ValueToLogTypes.LAST_5) { + return value.slice(-5); + } + return value; +} diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js new file mode 100644 index 00000000..0426ca29 --- /dev/null +++ b/src/mixins/analytics-mixin.spec.js @@ -0,0 +1,299 @@ +import analyticsMixin from "@/mixins/analytics-mixin"; +import { setupMocksForJsFiles, setupCookies } from "@/helpers/unit-test-helper.js"; +import { storeActions } from "@/constants/store-actions"; +import { + analyticsPageEvents, + GaCategories, + GaActions, + GaLabels, + GaEvents, + ValueToLogTypes, +} from "@/constants/analytics"; +import store from "@/store"; + +describe("analyticsMixin.js", () => { + test("logPageView: calls dispatch with type and payload", () => { + const type = ""; + const payload = {}; + + const mockData = { + actionList: [ + { + actionName: storeActions.LOG_PAGE_VIEW, + }, + ], + }; + const mocks = setupMocksForJsFiles(mockData); + + const testCookieValue = { + sid: "10000000-0000-0000-0000-000000000001", + }; + + setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); + + analyticsMixin.methods.logPageView(type, payload); + + expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); + }); + + test("logCustomEvent: calls dispatch with type and payload", () => { + const mockData = { + actionList: [ + { + actionName: storeActions.LOG_CUSTOM_EVENT, + }, + ], + }; + const mocks = setupMocksForJsFiles(mockData); + + analyticsMixin.methods.logCustomEvent("someCat", "someAction", "someLabel", "someVal"); + + expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); + }); + + test("pushEventToGA, should call dataLayer push and logCustomEvent too", () => { + // Arrange + window.dataLayer = []; + const mockData = { + actionList: [ + { + actionName: storeActions.LOG_CUSTOM_EVENT, + }, + ], + }; + const mocks = setupMocksForJsFiles(mockData); + var mockDataLayer = []; + mockDataLayer.push({ + event: "event", + category: "category", + action: "action", + label: "label", + value: undefined, + path: "/fmg/?fmgPage=", + }); + + // Act + analyticsMixin.methods.pushEventToGA("category", "action", "label", true); + + // Assert + expect(mockDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); + expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); + }); + + test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 only logs last 5 of label", () => { + // Arrange + window.dataLayer = []; + var expectedDataLayer = []; + expectedDataLayer.push({ + event: "event", + category: "category", + action: "action", + label: "33333", + value: undefined, + path: "/fmg/?fmgPage=", + }); + + // Act + analyticsMixin.methods.pushEventToGA( + "category", + "action", + "1111122222333333", + false, + ValueToLogTypes.LAST_5 + ); + + // Assert + expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); + }); + + test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 logs only the last 3 characters for a 3 character string", () => { + // Arrange + window.dataLayer = []; + var expectedDataLayer = []; + expectedDataLayer.push({ + event: "event", + category: "category", + action: "action", + label: "111", + value: undefined, + path: "/fmg/?fmgPage=", + }); + + // Act + analyticsMixin.methods.pushEventToGA( + "category", + "action", + "111", + false, + ValueToLogTypes.LAST_5 + ); + + // Assert + expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); + }); + + test("Experiments, should push to dataLayer with default Google Custom Dimension Index", () => { + // Arrange + window.dataLayer = []; + + const mockExperimentData = [ + { + settings: {}, + variationName: "test", + universeName: "testUniverse", + }, + ]; + + // Mock store + jest.mock( + "@/store", + () => { + return {}; + }, + { virtual: true } + ); + + store.getters = { + applicationUser: { + experiments: [ + { + settings: {}, + variationName: "test", + universeName: "testUniverse", + }, + ], + }, + }; + + // Act + analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); + + // Assert + expect(window.dataLayer).toEqual([ + { + experimentId_99: undefined, + variationId_99: undefined, + experimentName_99: "testUniverse", + variationName_99: "test", + customDimension_99: "undefined_undefined_testUniverse_test", + }, + ]); + }); + + test("Experiments, should push to dataLayer with custom Google Custom Dimension Index", () => { + // Arrange + window.dataLayer = []; + + const mockExperimentData = [ + { + settings: { "Google Custom Dimension Index": "5" }, + variationName: "test", + universeName: "testUniverse", + }, + ]; + + // Mock store + jest.mock( + "@/store", + () => { + return {}; + }, + { virtual: true } + ); + + store.getters = { + applicationUser: { + experiments: [ + { + settings: { "Google Custom Dimension Index": "5" }, + variationName: "test", + universeName: "testUniverse", + }, + ], + }, + }; + + // Act + analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); + + // Assert + expect(window.dataLayer).toEqual([ + { + experimentId_5: undefined, + variationId_5: undefined, + experimentName_5: "testUniverse", + variationName_5: "test", + customDimension_5: "undefined_undefined_testUniverse_test", + }, + ]); + }); + + test("Obj is not null after action prepended", () => { + //Arrange + const obj = { baseMethodName: "testMethodName", data: "testData" }; + const method = { name: "testMethodName", data: "testData" }; + const action = "testAction"; + + //Act + analyticsMixin.methods.prependActionToMethod(obj, method, action); + + //Assert + expect(obj != null); + }); + test("Obj method name does not include bound", () => { + //Arrange + const obj = { baseMethodName: "testMethodName", data: "testData" }; + const method = { name: "testMethodName", data: "testData" }; + const action = "testAction"; + + //Act + analyticsMixin.methods.prependActionToMethod(obj, method, action); + + //Assert + expect(method.name.startsWith("bound ")).toBe(false); + }); + test("Prepended action does not include bound", () => { + //Arrange + const obj = { baseMethodName: "testMethodName", data: "testData" }; + const method = { name: "testMethodName", data: "testData" }; + const action = "testAction"; + + //Act + analyticsMixin.methods.prependActionToMethod(obj, method, action); + + //Assert + expect(action.startsWith("bound ")).toBe(false); + }); + + test("analyticsPageEvents returns constants analyticsPageEvents", () => { + //Act + const analyticsPE = analyticsMixin.computed.analyticsPageEvents(); + + //Assert + expect(analyticsPE).toEqual(analyticsPageEvents); + }); + + test("GaActions returns constants GaActions", () => { + //Act + const gaActions = analyticsMixin.computed.GaActions(); + + //Assert + expect(gaActions).toEqual(GaActions); + }); + + test("GaCategories returns constants GaCategories", () => { + //Act + const gaCategories = analyticsMixin.computed.GaCategories(); + + //Assert + expect(gaCategories).toEqual(GaCategories); + }); + + test("GaLabels returns constants GaLabels", () => { + //Act + const gaLabels = analyticsMixin.computed.GaLabels(); + + //Assert + expect(gaLabels).toEqual(GaLabels); + }); +});