diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 6b1e69609..0a67c8c96 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -54,6 +54,10 @@ const endpoints = { url: "/vehicle/api/v1/vehicle/lookup-vin-by-address", method: "POST", }, + IsVinByAddressPermissible: { + url: "/vehicle/api/v1/vehicle/is-vin-by-address-permissible", + method: "GET", + }, GetPartsOrQuestions: { url: "/parts/api/v1/parts/parts-or-questions", method: "POST", diff --git a/src/constants/query-strings.js b/src/constants/query-strings.js index d2dbcf976..5ff1f636c 100644 --- a/src/constants/query-strings.js +++ b/src/constants/query-strings.js @@ -1,7 +1,7 @@ const queryStrings = { FMG_PAGE: "fmgPage", START_TYPE: "start_type", - ZIP_CODE: "zipCode", + ZIP_CODE: "zipcode", }; export { queryStrings }; diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 33e7c4fa1..11e03bdb3 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -13,6 +13,7 @@ const storeActions = { GET_DAMAGE_OPTIONS: "getDamageOptions", GET_EVOX_IMAGE: "getEvoxImage", IS_VIN_OPTIONAL_VEHICLE: "isVinOptionalVehicle", + IS_VIN_BY_ADDRESS_PERMISSIBLE: "isVinByAddressPermissible", // Lookup Actions LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms", diff --git a/src/helpers/object-cloning-helper.js b/src/helpers/object-cloning-helper.js new file mode 100644 index 000000000..45899973f --- /dev/null +++ b/src/helpers/object-cloning-helper.js @@ -0,0 +1,27 @@ +// For nested objects, spread operator only creates new references to the top level fields, +// the remaining nested fields actually reference the original object which can introduce problems. + +// The purpose of this method is to deep clone the data in an object recursively, this is useful +// for cloning modelValues to internal models when regular two-way binding is not an option. +// See: mobile-location-modal-questions.vue + +// Creates a deep clone of an object. Clones primitives, arrays and objects, excluding class instances. +// https://www.30secondsofcode.org/js/s/deep-clone +export function deepClone(object) { + if (object === null) { + return null; + } + + let clone = Object.assign({}, object); + Object.keys(clone).forEach( + (key) => + (clone[key] = typeof object[key] === "object" ? deepClone(object[key]) : object[key]) + ); + + if (Array.isArray(object)) { + clone.length = object.length; + return Array.from(clone); + } + + return clone; +} diff --git a/src/helpers/object-cloning-helper.spec.js b/src/helpers/object-cloning-helper.spec.js new file mode 100644 index 000000000..6408992d8 --- /dev/null +++ b/src/helpers/object-cloning-helper.spec.js @@ -0,0 +1,48 @@ +import { deepClone } from "./object-cloning-helper"; + +describe("object-cloning-helper.js", () => { + it("Should return null if no object is passed in", async () => { + // Arrange + const expected = null; + + // Act + const result = deepClone(null); + + // Assert + expect(result).toEqual(expected); + }); + + it("Should return a deep copy of the object", async () => { + // Arrange + + const object = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "55555", + }, + isVehicleProtected: true, + serviceZipCode: "55555", + }; + + const expected = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "55555", + }, + isVehicleProtected: true, + serviceZipCode: "55555", + }; + + // Act + const result = deepClone(object); + + // Assert + expect(result).toStrictEqual(expected); + }); +}); diff --git a/src/helpers/service-location-helper.js b/src/helpers/service-location-helper.js index 8f34de930..41f4be147 100644 --- a/src/helpers/service-location-helper.js +++ b/src/helpers/service-location-helper.js @@ -1,9 +1,9 @@ import { storeActions } from "@/constants/store-actions"; import baseMixin from "@/mixins/base-mixin.js"; -export async function getMobileFee(serviceZipCode) { +export async function getPricedMobileFeePart(serviceZipCode) { if (!serviceZipCode) { - return 0; + return Promise.resolve(null); } const zipCodeData = await baseMixin.methods.getZipCodeData(serviceZipCode); @@ -26,5 +26,5 @@ export async function getMobileFee(serviceZipCode) { false ); - return Promise.resolve(baseMixin.methods.getTotalLineItemPrice(pricingResults[0])); + return Promise.resolve(pricingResults[0]); } diff --git a/src/helpers/service-location-helper.spec.js b/src/helpers/service-location-helper.spec.js index 3d0843e10..345e4c807 100644 --- a/src/helpers/service-location-helper.spec.js +++ b/src/helpers/service-location-helper.spec.js @@ -1 +1,78 @@ -test.todo("some test to be written in the future"); +import { getPricedMobileFeePart } from "./service-location-helper"; +import { storeActions } from "@/constants/store-actions"; + +const mockStoreActionGetMobileFeePart = storeActions.GET_MOBILE_FEE_PART; +const mockStoreActionPriceOrderItemsAndSaveServerData = + storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA; + +jest.mock("@/mixins/base-mixin.js", () => ({ + methods: { + getZipCodeData: jest.fn().mockImplementation(() => { + return Promise.resolve({ + containsMilitaryBase: false, + isServiceable: true, + isValid: true, + state: "OH", + zipCodeCtu: "01820", + }); + }), + + dispatchStoreAction: jest.fn().mockImplementation((actionName) => { + if (actionName === mockStoreActionGetMobileFeePart) { + return Promise.resolve({ + data: { + partNumber: "MOBILE FEE", + description: "MOBILE FEE", + partType: "FEE", + }, + }); + } + + if (actionName === mockStoreActionPriceOrderItemsAndSaveServerData) { + return Promise.resolve([ + { + partNumber: "MOBILE FEE", + description: "MOBILE FEE", + partType: "FEE", + laborAmount: 49.99, + sellingPrice: 0, + kitPrice: 0, + }, + ]); + } + }), + }, +})); + +describe("service-location-helper.js", () => { + it("Should return null if no service zip code is passed in", async () => { + // Arrange + const serviceZipCode = null; + const expected = null; + + // Act + const result = await getPricedMobileFeePart(serviceZipCode); + + // Assert + expect(result).toEqual(expected); + }); + + it("Should return the priced mobile fee part", async () => { + // Arrange + const serviceZipCode = "43235"; + const expected = { + partNumber: "MOBILE FEE", + description: "MOBILE FEE", + partType: "FEE", + laborAmount: 49.99, + sellingPrice: 0, + kitPrice: 0, + }; + + // Act + const result = await getPricedMobileFeePart(serviceZipCode); + + // Assert + expect(result).toEqual(expected); + }); +}); diff --git a/src/layouts/estimate/estimate.vue b/src/layouts/estimate/estimate.vue index b9fa11f81..812870f83 100644 --- a/src/layouts/estimate/estimate.vue +++ b/src/layouts/estimate/estimate.vue @@ -110,6 +110,8 @@ import { experimentSettings } from "@/constants/experiments"; import vinPagesMixin from "@/mixins/vin-pages-mixin"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; +import baseMixin from "@/mixins/base-mixin.js"; +import { queryStrings } from "@/constants/query-strings"; // Define Validation Rules defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); @@ -130,7 +132,7 @@ export default { data() { return { selectedVinLookupMethod: null, - serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipCode, + serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipcode, emailAddress: this.getEmailFromStore(), displayInvalidZipAlert: false, displayNonServiceableZipAlert: false, @@ -143,12 +145,31 @@ export default { //Call APIs const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage); + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const hasZip = urlParams.has(queryStrings.ZIP_CODE); + const zip = urlParams.get(queryStrings.ZIP_CODE); + + var vinByAddressPromise; + if (hasZip) { + vinByAddressPromise = baseMixin.methods.dispatchStoreAction( + storeActions.IS_VIN_BY_ADDRESS_PERMISSIBLE, + zip, + false + ); + } + //Settle promises and get results const promiseResultMap = [ { resultKey: "cmsContent", promise: cmsContentPromise, }, + zip != null && + zip != undefined && { + resultKey: "vinByAddress", + promise: vinByAddressPromise, + }, ]; const resultMap = await settleAllPromises(promiseResultMap); @@ -170,6 +191,14 @@ export default { } } + if (zip && resultMap.vinByAddress === false) { + var indexToRemove = resultMap.cmsContent.VinLookupMethod.Answers.findIndex( + (answer) => answer.Name === "HomeAddress" + ); + if (indexToRemove) { + resultMap.cmsContent.VinLookupMethod.Answers.splice(indexToRemove, 1); + } + } vm.setCmsContent(resultMap.cmsContent); }); }, diff --git a/src/layouts/license-plate-lookup/license-plate-lookup.vue b/src/layouts/license-plate-lookup/license-plate-lookup.vue index da529662f..efda89c97 100644 --- a/src/layouts/license-plate-lookup/license-plate-lookup.vue +++ b/src/layouts/license-plate-lookup/license-plate-lookup.vue @@ -152,7 +152,7 @@ export default { data() { return { licensePlate: this.getLicensePlateFromStore(), - registrationZipCode: this.getRegistrationZipFromStore() ?? this.$route.query.zipCode, + registrationZipCode: this.getRegistrationZipFromStore() ?? this.$route.query.zipcode, email: this.getEmailFromStore(), serviceZipCode: this.getServiceZipFromStore(), displayNonServiceableZipAlert: false, diff --git a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js index 7e5aaff8c..f7a2238c8 100644 --- a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js +++ b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js @@ -2,6 +2,7 @@ import mobileLocationModalQuestions from "./mobile-location-modal-questions"; import { mount, shallowMount } from "@vue/test-utils"; import { storeActions } from "@/constants/store-actions"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import modal from "@/digital-components/modal/modal"; import crypto from "crypto"; @@ -42,7 +43,7 @@ const mockMixin = { }), getZipCodeData: jest.fn((zip) => { - if (zip === "43235") { + if (zip === "43235" || zip === "55555") { return Promise.resolve({ containsMilitaryBase: false, isServiceable: true, @@ -128,6 +129,213 @@ describe("mobile-location-modal-questions.vue", () => { expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(true); expect(wrapper.vm.internalModel.serviceZipCode).toEqual("55555"); }); + + it("Should open the modal when the 'Enter your service address' link is clicked", async () => { + // Arrange + const mobileLocationQuestions = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "55555", + }, + isVehicleProtected: true, + serviceZipCode: "55555", + }; + + const { wrapper } = setupMocks({ + mixins: [mockMixin], + props: { + modelValue: mobileLocationQuestions, + }, + mountOptions: { + attachTo: document.body, + }, + }); + + wrapper.vm.$refs.MobileLocationModalWidget.openModal = jest.fn(); + const mobileLocationLink = wrapper.findComponent({ ref: "mobileLocationLink" }); + + // // Act + await mobileLocationLink.trigger("click-event"); + + // // Assert + expect(wrapper.vm.$refs.MobileLocationModalWidget.openModal).toHaveBeenCalled(); + }); + + it("Should emit update:modelValue on setMobileLocation for a valid address and vehicle protection answer", async () => { + // Arrange + const mobileLocationQuestions = { + addressQuestions: { + streetAddress: "", + apartmentNumberOrBusinessName: "", + city: "", + state: "", + zipCode: "", + }, + isVehicleProtected: true, + serviceZipCode: "", + }; + + const newMobileLocationQuestions = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "55555", + }, + isVehicleProtected: true, + serviceZipCode: "55555", + }; + + const wrapper = shallowMount(mobileLocationModalQuestions, { + mixins: [mockMixin], + props: { + modelValue: mobileLocationQuestions, + linkWidgetName: linkWidgetName, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn(); + + // Act + wrapper.vm.internalModel = newMobileLocationQuestions; + await wrapper.vm.setMobileLocation(); + + let expectedEmit = [[newMobileLocationQuestions]]; + + // Assert + expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit); + }); + + it("Should not emit update:modelValue on setMobileLocation for an invalid address zip code", async () => { + // Arrange + const mobileLocationQuestions = { + addressQuestions: { + streetAddress: "", + apartmentNumberOrBusinessName: "", + city: "", + state: "", + zipCode: "", + }, + isVehicleProtected: true, + serviceZipCode: "", + }; + + const newMobileLocationQuestions = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "61000", + }, + isVehicleProtected: true, + serviceZipCode: "61000", + }; + + const wrapper = shallowMount(mobileLocationModalQuestions, { + mixins: [mockMixin], + props: { + modelValue: mobileLocationQuestions, + linkWidgetName: linkWidgetName, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn(); + + // Act + wrapper.vm.internalModel = newMobileLocationQuestions; + await wrapper.vm.setMobileLocation(); + + // Assert + expect(wrapper.emitted("update:modelValue")).not.toBeTruthy(); + }); + + it("Should display invalid zip alert for invalid address zip inputs", async () => { + // Arrange + const mobileLocationQuestions = { + addressQuestions: { + streetAddress: "", + apartmentNumberOrBusinessName: "", + city: "", + state: "", + zipCode: "", + }, + isVehicleProtected: true, + serviceZipCode: "", + }; + + const newMobileLocationQuestions = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "11111", + }, + isVehicleProtected: true, + serviceZipCode: "11111", + }; + + const wrapper = shallowMount(mobileLocationModalQuestions, { + mixins: [mockMixin], + props: { + modelValue: mobileLocationQuestions, + linkWidgetName: linkWidgetName, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + + wrapper.vm.internalModel = newMobileLocationQuestions; + + // Act + await wrapper.vm.setMobileLocation(); + + // Assert + expect(wrapper.vm.displayInvalidZipAlert).toBe(true); + }); + + it("Should clear the internal model when resetModel is called", async () => { + // Arrange + const mobileLocationQuestions = { + addressQuestions: { + streetAddress: "555 Some St", + apartmentNumberOrBusinessName: "Apt 1", + city: "Funkytown", + state: "OH", + zipCode: "55555", + }, + isVehicleProtected: true, + serviceZipCode: "55555", + }; + + const { wrapper } = setupMocks({ + mixins: [mockMixin], + props: { + modelValue: mobileLocationQuestions, + }, + mountOptions: { + attachTo: document.body, + }, + }); + + // Act + wrapper.vm.resetModel(); + + // Assert + expect(wrapper.vm.internalModel.addressQuestions.streetAddress).toEqual(""); + expect(wrapper.vm.internalModel.addressQuestions.apartmentNumberOrBusinessName).toEqual(""); + expect(wrapper.vm.internalModel.addressQuestions.city).toEqual(""); + expect(wrapper.vm.internalModel.addressQuestions.state).toEqual(""); + expect(wrapper.vm.internalModel.addressQuestions.zipCode).toEqual(""); + expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(null); + }); }); function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) { diff --git a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue index a15709f67..7523e6c83 100644 --- a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue +++ b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue @@ -1,23 +1,15 @@