diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js index 42f33d88..2f2faeac 100644 --- a/src/mixins/analytics-mixin.spec.js +++ b/src/mixins/analytics-mixin.spec.js @@ -112,10 +112,12 @@ describe("analyticsMixin.js", () => { // Assert expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); }); -/* + test("Experiments, should push to dataLayer with default Google Custom Dimension Index", () => { // Arrange - window.dataLayer = []; + const store = useMainStore(); + + window.dataLayer = []; const mockExperimentData = [ { @@ -125,26 +127,7 @@ describe("analyticsMixin.js", () => { }, ]; - // Mock store - jest.mock( - "@/store", - () => { - return {}; - }, - { virtual: true } - ); - - useMainStore.getters = { - applicationUserObj: { - experiments: [ - { - settings: {}, - variationName: "test", - universeName: "testUniverse", - }, - ], - }, - }; + store.applicationUserObj.experiments = mockExperimentData; // Act analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); @@ -163,7 +146,9 @@ describe("analyticsMixin.js", () => { test("Experiments, should push to dataLayer with custom Google Custom Dimension Index", () => { // Arrange - window.dataLayer = []; + const store = useMainStore(); + + window.dataLayer = []; const mockExperimentData = [ { @@ -173,26 +158,7 @@ describe("analyticsMixin.js", () => { }, ]; - // Mock store - jest.mock( - "@/store", - () => { - return {}; - }, - { virtual: true } - ); - - useMainStore.getters = { - applicationUserObj: { - experiments: [ - { - settings: { "Google Custom Dimension Index": "5" }, - variationName: "test", - universeName: "testUniverse", - }, - ], - }, - }; + store.applicationUserObj.experiments = mockExperimentData; // Act analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); @@ -208,7 +174,7 @@ describe("analyticsMixin.js", () => { }, ]); }); -*/ + test("Obj is not null after action prepended", () => { //Arrange const obj = { baseMethodName: "testMethodName", data: "testData" }; diff --git a/src/mixins/experiment-mixin.js b/src/mixins/experiment-mixin.js index 61561239..acf1b23e 100644 --- a/src/mixins/experiment-mixin.js +++ b/src/mixins/experiment-mixin.js @@ -3,14 +3,14 @@ import { useMainStore } from "@/store"; export default { methods: { hasSettingEqualTo(settingName, settingValue) { - return useMainStore.experimentSettings[settingName] == settingValue; + return useMainStore().experimentSettings[settingName] == settingValue; }, hasSetting(settingName) { - return Object.hasOwn( useMainStore.experimentSettings, settingName); + return Object.hasOwn( useMainStore().experimentSettings, settingName); }, getSettingValue(settingName) { return this.hasSetting(settingName) - ? useMainStore.experimentSettings[settingName] + ? useMainStore().experimentSettings[settingName] : null; }, }, diff --git a/src/mixins/experiment-mixin.spec.js b/src/mixins/experiment-mixin.spec.js new file mode 100644 index 00000000..6267a3e4 --- /dev/null +++ b/src/mixins/experiment-mixin.spec.js @@ -0,0 +1,155 @@ +import experimentMixin from "@/mixins/experiment-mixin"; +import { shallowMount } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { useMainStore } from "@/store"; + +describe("experiment-mixin", () => { + describe("hasSettingEqualTo", () => { + test("setting exists and value matches => return true", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.hasSettingEqualTo("Setting2", "Value2"); + + // Assert + expect(result).toEqual(true); + }); + + test("setting exists and value does not match => return false", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.hasSettingEqualTo("Setting2", "Value3"); + + // Assert + expect(result).toEqual(false); + }); + + test("setting does not exist => return false", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.hasSettingEqualTo("SettingBoogly", "Woogly"); + + // Assert + expect(result).toEqual(false); + }); + + test("there are no settings => return false", () => { + // Arrange + const { wrapper } = setupMocks({ experimentSettings: {} }); + + // Act + const result = wrapper.vm.hasSettingEqualTo("Setting2", "Value3"); + + // Assert + expect(result).toEqual(false); + }); + }); + + describe("hasSetting", () => { + test("has setting => return true", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.hasSetting("Setting4"); + + // Assert + expect(result).toEqual(true); + }); + + test("does not have setting => return false", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.hasSetting("BooglyWoogly"); + + // Assert + expect(result).toEqual(false); + }); + + test("experimentSettings is empty => return false", () => { + // Arrange + const { wrapper } = setupMocks({ experimentSettings: {} }); + + // Act + const result = wrapper.vm.hasSetting("Setting4"); + + // Assert + expect(result).toEqual(false); + }); + }); + + describe("getSettingValue", () => { + test("has setting => return correct value", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.getSettingValue("Setting3"); + + // Assert + expect(result).toEqual("Value3"); + }); + + test("does not have setting => return null", () => { + // Arrange + const { wrapper } = setupMocks({}); + + // Act + const result = wrapper.vm.getSettingValue("Hello"); + + // Assert + expect(result).toBeNull(); + }); + + test("experimentSettings is empty => return null", () => { + // Arrange + const { wrapper } = setupMocks({ experimentSettings: {} }); + + // Act + const result = wrapper.vm.getSettingValue("Hello"); + + // Assert + expect(result).toBeNull(); + }); + }); +}); + +function setupMocks({ experimentSettings }) { + const store = useMainStore(); + const mocks = getMountOptions({}); + + const testExperimentSettings = { + Setting1: "Value1", + Setting2: "Value2", + Setting3: "Value3", + Setting4: "Value1", + Setting5: "Value2", + Setting6: "Value3", + }; + + const mockExperimentData = [ + { + settings: experimentSettings ?? testExperimentSettings, + variationName: "test", + universeName: "testUniverse", + }, + ]; + + store.applicationUser.experiments = mockExperimentData; + + const mockComponent = { + template: "
", + mixins: [experimentMixin], + }; + + const wrapper = shallowMount(mockComponent, mocks); + + return { wrapper }; +} diff --git a/src/router/index.js b/src/router/index.js index 4c3f435e..3f647a5f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -5,6 +5,13 @@ import { routingTable } from "@/router/router-constants/routing-table"; import { useMainStore } from '@/store'; import eventBus from "@/helpers/event-bus/event-bus"; import { globalEvents, globalEventTypes } from "@/constants/events"; +import { + getDeviceIdValue, + updateOrCreateISSCookie, + updateSessionIdCookie, +} from "@/helpers/cookie-helper"; +import { experimentTriggers } from "@/constants/experiments"; +import { applicationConfig } from "@/constants/application-config"; import analyticsMixin from "@/mixins/analytics-mixin"; @@ -13,8 +20,19 @@ const routes = [ path: '/', name: 'root', async beforeEnter(to, from, next) { - try { + try { to.query.issPage = !to.query.issPage ? issPageValues.VEHICLE_YEAR : to.query.issPage; + + if (analyticsMixin.methods.noSession()) { + await analyticsMixin.methods.initSession(); + } else { + updateSessionIdCookie(); + } + + await runExperiments(to.query.issPage); + + // Process ISS cookie. + updateOrCreateISSCookie(); if (router.hasRoute(to.query.issPage)) { return next({ name: to.query.issPage, query: to.query, params: to.params }); @@ -184,4 +202,23 @@ function GoToStartOn404(next) { }; +// Run SiteEntry and PageEntry triggers for experiments +async function runExperiments(nextPage) { + const store = useMainStore(); + + if (!store.applicationUser.triggeredSiteEntry) { + await store.runExperimentsForTrigger({ + userId: getDeviceIdValue(), + triggerEvent: experimentTriggers.SITE_ENTRY, + triggerValue: applicationConfig.SITE_ENTRY_TRIGGER_VALUE, + }); + } + + await store.runExperimentsForTrigger({ + userId: getDeviceIdValue(), + triggerEvent: experimentTriggers.PAGE_ENTRY, + triggerValue: nextPage, + }); +} + export default router; \ No newline at end of file diff --git a/src/store/index.js b/src/store/index.js index 20e594cf..f29b2a4e 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,8 +2,10 @@ import { defineStore } from "pinia"; import { endpoints } from "@/constants/endpoints.js"; import { getDateForSavedSessionTimeout } from "@/helpers/session-helper"; import globalMethods from "@/global-methods"; +import { experimentTriggers } from "@/constants/experiments"; import { applicationConfig } from "@/constants/application-config"; import { issPageValues } from "@/router/router-constants/issPage-values"; +import { damageLocationsSelected } from "@/constants/damage-locations-selected"; const storeId = 'main'; @@ -42,6 +44,18 @@ const getDefaultState = () => { lineItems: { glassParts: null, }, + payment: { + isInsurance: true, + insuranceCoverage: { + isVerified: false, + }, + }, + serviceLocation: { + address: null, + city: null, + state: null, + zipCode: null, + }, referralNumber: null, referralDate: null, accountNumber: 0, @@ -85,38 +99,37 @@ export const useMainStore = defineStore({ }, experimentOrder: (state) => { return { - funnelVehicleYear: state.order.vehicle.year, - funnelVehicleMake: state.order.vehicle.make, - funnelVehicleModel: state.order.vehicle.model, - funnelVehicleStyle: state.order.vehicle.style, - funnelIsRepair: state.order.damage.isRepair, - funnelNumberOfChips: state.order.damage.numberOfChips, - funnelCarId: state.order.vehicle.carId, - funnelServiceCity: state.order.serviceLocation.city, - funnelServiceState: state.order.serviceLocation.state, - funnelServiceZipCode: state.order.serviceLocation.zipCode, - funnelParentAccountNumber: state.order.accountNumber, - funnelIsCoverageVerified: state.order.payment.insuranceCoverage.isVerified, - funnelHasRecalibrationPart: getHasRecalibrationPart(state), - funnelSelectedMultiGlass: state.order.damage.glassToReplace?.length > 1, - funnelSelectedWindshieldGlass: getNonFalseValuesOfPropertyInArrayOfObjects( + issVehicleYear: state.order.vehicle.year, + issVehicleMake: state.order.vehicle.make, + issVehicleModel: state.order.vehicle.model, + issVehicleStyle: state.order.vehicle.style, + issIsRepair: state.order.damage.isRepair, + issNumberOfChips: state.order.damage.numberOfChips, + issCarId: state.order.vehicle.carId, + issServiceCity: state.order.serviceLocation.city, + issServiceState: state.order.serviceLocation.state, + issServiceZipCode: state.order.serviceLocation.zipCode, + issParentAccountNumber: state.order.accountNumber, + issIsCoverageVerified: state.order.payment.insuranceCoverage.isVerified, + issHasRecalibrationPart: getHasRecalibrationPart(state), + issSelectedMultiGlass: state.order.damage.glassToReplace?.length > 1, + issSelectedWindshieldGlass: getNonFalseValuesOfPropertyInArrayOfObjects( state.order.damage.glassToReplace, "glassLocation" ).includes(damageLocationsSelected.WINDSHIELD), - funnelSelectedBackGlass: getNonFalseValuesOfPropertyInArrayOfObjects( + issSelectedBackGlass: getNonFalseValuesOfPropertyInArrayOfObjects( state.order.damage.glassToReplace, "glassLocation" ).includes(damageLocationsSelected.REAR), - funnelSelectedDriverSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects( + issSelectedDriverSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects( state.order.damage.glassToReplace, "glassLocation" ).includes(damageLocationsSelected.DRIVER), - funnelSelectedPassengerSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects( + issSelectedPassengerSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects( state.order.damage.glassToReplace, "glassLocation" ).includes(damageLocationsSelected.PASSENGER), - - funnelOrderPartNumbers: [ + issOrderPartNumbers: [ ...getNonFalseValuesOfPropertyInArrayOfObjects( state.order.lineItems.glassParts, "partNumber" @@ -127,7 +140,7 @@ export const useMainStore = defineStore({ ), ], - funnelOrderPartTypes: [ + issOrderPartTypes: [ ...getNonFalseValuesOfPropertyInArrayOfObjects( state.order.lineItems.glassParts, "recalibrationType" @@ -483,6 +496,13 @@ export const useMainStore = defineStore({ this.applicationUser.lastPageVisited = lastPageVisited; }, + updateExperiments(experiments) { + this.applicationUser.experiments = experiments; + }, + updateTriggeredSiteEntry(wasSiteEntryTriggered) { + this.applicationUser.triggeredSiteEntry = wasSiteEntryTriggered; + }, + GetExperimentsByUser(userId) { return globalMethods.callHttpClient({ method: endpoints.GetExperimentsByUser.method, @@ -490,7 +510,67 @@ export const useMainStore = defineStore({ payload: {}, }); }, + + async runExperimentsForTrigger({ userId, triggerEvent, triggerValue }) { + if (triggerEvent == experimentTriggers.SITE_ENTRY) { + this.updateTriggeredSiteEntry(true); + } + + var payload = { + applicationName: applicationConfig.APPLICATION_NAME, + userId: userId, + triggerEvent: triggerEvent, + triggerValue: triggerValue, + experimentOrder: this.experimentOrder, + }; + + const response = await globalMethods.callHttpClient({ + method: endpoints.RunExperimentsForTrigger.method, + endpoint: endpoints.RunExperimentsForTrigger.url, + payload: payload, + }); + + this.updateExperiments(response.data.experiments); + }, }, persist: true }); + + +// Private Functions + +function getHasRecalibrationPart(state) { + var hasRequiresRecalibration = + getNonFalseValuesOfPropertyInArrayOfObjects( + state.order.lineItems.glassParts, + "requiresRecalibration" + )?.length > 0; + var hasRecalibrationType = + getNonFalseValuesOfPropertyInArrayOfObjects( + state.order.lineItems.glassParts, + "recalibrationType" + )?.length > 0; + + if (hasRequiresRecalibration) { + if (hasRecalibrationType) { + // Has both 'requiresRecalibration' and 'recalibrationType' and 'recalibrationType' + return ( + getNonFalseValuesOfPropertyInArrayOfObjects( + state.order.lineItems.glassParts, + "recalibrationType" + )[0].toLowerCase() != "unknown" + ); + } else { + // Has 'requiresRecalibration' but no 'recalibrationType' at all + return true; + } + } else { + // Does not have 'requiresRecalibration' + return false; + } +} + +function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) { + return (array ?? []).map((x) => x[propertyName]).filter((x) => x); +}