diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 4196cbf19..3035b4ad7 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -112,10 +112,18 @@ const endpoints = { url: "/schedule/api/v1/schedule/shop-time-slots", method: "POST", }, + GetShopTimeSlotsV2: { + url: "/schedule/api/v2/schedule/shop-time-slots", + method: "POST", + }, GetMobileTimeSlots: { url: "/schedule/api/v1/schedule/mobile-time-slots", method: "POST", }, + GetMobileTimeSlotsV2: { + url: "/schedule/api/v2/schedule/mobile-time-slots", + method: "POST", + }, GetMobilePremiumFee: { url: "/parts/api/v1/parts/mobile-premium-fee", method: "GET", diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index 1b0dfb724..2f49e2f1b 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -41,6 +41,9 @@ const errorMessages = { INSURANCE_COMPANY_REQUIRED: "Please select an insurance company", INSURANCE_COMPANY_NAME_REQUIRED: "Please enter your insurance company name", POLICY_NUMBER_REQUIRED: "Please enter your policy number", + POLICY_NUMBER_FORMAT: "Please enter a valid policy number", + CLAIM_NUMBER_VALID: "Please enter a valid claim number", + CLAIM_NUMBER_FORMAT: "Claim number must be 6 to 8 digits", DATE_OF_LOSS_REQUIRED: "Please enter your date of damage", DAMAGE_TYPES_REQUIRED: "How damage occurred is required", ZIP_CODE_REQUIRED: "Please enter your ZIP code", diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 7e136fefa..cb4c6ff54 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -35,7 +35,9 @@ const storeActions = { GET_PRICING_BY_DAY_PART: "getPricingByDayPart", GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails", GET_SHOP_TIME_SLOTS: "getShopTimeSlots", + GET_SHOP_TIME_SLOTS_V2: "getShopTimeSlotsV2", GET_MOBILE_TIME_SLOTS: "getMobileTimeSlots", + GET_MOBILE_TIME_SLOTS_V2: "getMobileTimeSlotsV2", GET_PROVIDERS: "getProviders", GET_MOBILE_PREMIUM_FEE: "getMobilePremiumFee", SAVE_SESSION: "saveSession", diff --git a/src/digital-components/textbox-question/textbox-question.spec.js b/src/digital-components/textbox-question/textbox-question.spec.js index d83eb6509..02fc6a768 100644 --- a/src/digital-components/textbox-question/textbox-question.spec.js +++ b/src/digital-components/textbox-question/textbox-question.spec.js @@ -1,4 +1,5 @@ import { shallowMount } from "@vue/test-utils"; +import { defineRule } from "vee-validate"; import textboxQuestion from "./textbox-question"; // Mock CMS content @@ -12,6 +13,11 @@ const mockMixin = { }; const maska = jest.fn(); +// Real rules registered with vee-validate so the watcher's pre-check (which calls the +// standalone `validate` export, not `this.validate`) exercises genuine valid/invalid paths. +defineRule("spec-always-valid", () => true); +defineRule("spec-always-invalid", () => "invalid"); + describe("textboxQuestion.vue", () => { it("Should coerce non-string modelValue to empty string for v-model.trim", async () => { const wrapper = shallowMount(textboxQuestion, { @@ -148,7 +154,7 @@ describe("textboxQuestion.vue", () => { expect(wrapper.emitted()).toHaveProperty("change"); }); - it("Should call this.handleChange with new value when the value is changed and the new value is valid", async () => { + it("Should call this.handleChange with new value and shouldValidate=true when the value is changed and the new value is valid", async () => { // Arrange const wrapper = shallowMount(textboxQuestion, { global: { @@ -157,22 +163,43 @@ describe("textboxQuestion.vue", () => { }, }, propsData: { - options: {}, modelValue: "foo", + validationRules: "spec-always-valid", }, mixins: [mockMixin], }); wrapper.vm.handleChange = jest.fn().mockImplementation(() => {}); - wrapper.vm.validate = jest.fn().mockImplementation(() => { - return true; - }); // Act - wrapper.vm.$options.watch.value.call(wrapper.vm, "bar"); + await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar"); // Assert - expect(wrapper.vm.handleChange).toHaveBeenCalled; + expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", true); + }); + + it("Should still call this.handleChange with shouldValidate=false when the new value is invalid, so the field stays in sync for later validation", async () => { + // Arrange + const wrapper = shallowMount(textboxQuestion, { + global: { + directives: { + maska: maska, + }, + }, + propsData: { + modelValue: "foo", + validationRules: "spec-always-invalid", + }, + mixins: [mockMixin], + }); + + wrapper.vm.handleChange = jest.fn().mockImplementation(() => {}); + + // Act + await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar"); + + // Assert + expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", false); }); it("Should not display camera or spinner icon when disabled", () => { diff --git a/src/digital-components/textbox-question/textbox-question.vue b/src/digital-components/textbox-question/textbox-question.vue index 0bc635feb..041af416c 100644 --- a/src/digital-components/textbox-question/textbox-question.vue +++ b/src/digital-components/textbox-question/textbox-question.vue @@ -252,9 +252,15 @@ export default { } const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation - if (result.valid) { - this.handleChange(newValue); // trigger full validation on this field only - } + // Always sync vee-validate's internal field value with what the user actually + // typed. Previously this only happened when the pre-check passed, so a field + // that never reached a "valid" intermediate state while typing (e.g. an + // 8-character alpha-only policy number) would never sync/validate at all, + // silently swallowing its error message on blur/submit. Passing result.valid + // as the shouldValidate flag keeps the no-flicker-while-typing behavior (errors + // aren't displayed until the value is actually valid or a full validation runs), + // while still keeping meta/value accurate for that later validation. + this.handleChange(newValue, result.valid); }, }, components: { diff --git a/src/layouts/coverage-statement/coverage-statement.spec.js b/src/layouts/coverage-statement/coverage-statement.spec.js index a54591f34..ca8ebee2e 100644 --- a/src/layouts/coverage-statement/coverage-statement.spec.js +++ b/src/layouts/coverage-statement/coverage-statement.spec.js @@ -3,6 +3,9 @@ import { shallowMount } from "@vue/test-utils"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; +import store from "@/store"; +import { applicationConfig } from "@/constants/application-config"; +import { coverageStatus } from "@/constants/insurance"; // Mock our module for promises. jest.mock("@/helpers/layout-helper.js", () => ({ @@ -37,14 +40,78 @@ describe("coverage-statement.vue", () => { }); }); - describe("methods", () => { - test("arePagePrerequisitesValid returns true", () => { + describe("arePagePrerequisitesValid", () => { + test("returns true when all prerequisites are valid", () => { const { wrapper } = setupMocks(); + expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true); }); + + test("returns true when claim and coverage is selected, regardless of other prerequisites", () => { + const { wrapper } = setupMocks(); + store.getters.order.payment.isClaimAndCoverage = true; + store.getters.order.serviceLocation.zipCode = null; + + expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true); + }); + + test("returns false when service zip info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.serviceLocation.zipCode = null; + + expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false); + }); + + test("returns false when glass parts or repair info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.damage.isRepair = false; + store.getters.order.lineItems.glassParts = []; + + expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false); + }); + + test("returns false when insurance info is invalid", () => { + const { wrapper } = setupMocks(); + store.getters.order.payment.isInsurance = null; + + expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false); + }); }); }); +function createValidOrderForCoverageStatement() { + return { + payment: { + isClaimAndCoverage: false, + isInsurance: false, + parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER, + insuranceCoverage: { + isVerified: true, + coverageStatus: coverageStatus.VERIFIED, + }, + }, + policy: { + currentDeductible: 1000, + policyNumber: "POL123", + isItac: false, + isNoComp: false, + }, + serviceLocation: { + zipCode: "00000", + zipCodeCtu: "00000", + }, + damage: { + isRepair: false, + }, + lineItems: { + glassParts: [{ id: "part1", partType: "WINDSHIELD" }], + promos: [], + supportingItems: [], + vaps: [], + }, + }; +} + function setupMocks() { const mockCmsContent = { FunnelHeaderWidget: { Text: "Header" }, @@ -55,6 +122,10 @@ function setupMocks() { fetchCmsContentForPage.mockResolvedValue(mockCmsContent); settleAllPromises.mockResolvedValue({ cmsContent: mockCmsContent }); + store.getters = { + order: createValidOrderForCoverageStatement(), + }; + const mountOptions = getMountOptions({ route: { name: "duplicate-check", query: {}, params: {} }, router: { @@ -63,19 +134,7 @@ function setupMocks() { navigateWithoutSaving: jest.fn(), }, store: { - getters: { - order: { - policy: { - currentDeductible: 1000, - }, - lineItems: { - glassParts: [], - promos: [], - supportingItems: [], - vaps: [], - }, - }, - }, + getters: store.getters, }, mixins: [ { diff --git a/src/layouts/coverage-statement/coverage-statement.vue b/src/layouts/coverage-statement/coverage-statement.vue index eec23bfee..e39f4a65b 100644 --- a/src/layouts/coverage-statement/coverage-statement.vue +++ b/src/layouts/coverage-statement/coverage-statement.vue @@ -87,6 +87,12 @@ import contentGroupModal from "@/fmg-components/content-group-modal/content-grou import textBlock from "@/digital-components/text-block/text-block.vue"; import { debugLog } from "@/helpers/debug-log-helper"; import { loadSessionIfPresent } from "@/helpers/heritage-integration/order-helper"; +import { + flushPagePrereqsLogs, + hasServiceZipInfo, + hasInsuranceInfo, + hasGlassPartsOrRepairInfo, +} from "@/helpers/page-prerequisites-helper.js"; export default { name: "coverage-statement", mixins: [], @@ -199,7 +205,20 @@ export default { ); }, arePagePrerequisitesValid() { - return true; + const order = store.getters.order; + if (order.payment.isClaimAndCoverage) { + return true; //TODO: Future card when C&C flow is implemented + } + + const logQueue = []; + + const serviceZip = hasServiceZipInfo(order, logQueue); + const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue); + const insuranceInfo = hasInsuranceInfo(order, logQueue); + const preReqResult = serviceZip && glassPartsOrRepair && insuranceInfo; + + flushPagePrereqsLogs("coverage-statement.vue", preReqResult, logQueue); + return preReqResult; }, openModalAction(modalName) { this.$refs[modalName]?.openModal(); diff --git a/src/layouts/insurance-details/insurance-details.vue b/src/layouts/insurance-details/insurance-details.vue index 6414d33de..6142b86d1 100644 --- a/src/layouts/insurance-details/insurance-details.vue +++ b/src/layouts/insurance-details/insurance-details.vue @@ -46,11 +46,12 @@ + inputId="claimNumber" + maxLength="8" + validationRules="claim-number-numeric|claim-number-format" /> import funnelHeader from "@/fmg-components/funnel-header/funnel-header"; import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header"; -import { parentAccountNumbers } from "@/constants/insurance"; import buttonMain from "@/ux-components/button-main/button-main"; import { Form } from "vee-validate"; @@ -98,9 +97,6 @@ export default { displayDisclaimerText() { return this.disclaimerText !== null && this.disclaimerText !== ""; }, - parentAccountNumbers() { - return parentAccountNumbers; - }, }, methods: { diff --git a/src/layouts/policy-info/policy-info.spec.js b/src/layouts/policy-info/policy-info.spec.js index b86cce714..03929395d 100644 --- a/src/layouts/policy-info/policy-info.spec.js +++ b/src/layouts/policy-info/policy-info.spec.js @@ -1,8 +1,11 @@ import policyInfo from "@/layouts/policy-info/policy-info.vue"; -import { shallowMount } from "@vue/test-utils"; +import { shallowMount, mount, flushPromises } from "@vue/test-utils"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; +import { validate, Form } from "vee-validate"; +import { errorMessages } from "@/constants/error-messages"; +import textboxQuestion from "@/digital-components/textbox-question/textbox-question"; // Mock our module for promises. jest.mock("@/helpers/layout-helper.js", () => ({ @@ -43,6 +46,391 @@ describe("policy-info.vue", () => { expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true); }); }); + + describe("forwardButtonAction", () => { + // Regression coverage: clicking Continue must explicitly (re)validate the form + // rather than assuming meta.valid/isForwardActionDisabled already caught everything, + // since that was the actual gap behind "I typed an invalid policy number, clicked + // Continue, and the message never appeared". + test("does not save or navigate when the form is invalid", async () => { + const { wrapper } = setupMocks(); + wrapper.vm.isFormValid = jest.fn().mockResolvedValue(false); + + await wrapper.vm.forwardButtonAction(); + + expect(wrapper.vm.isFormValid).toHaveBeenCalledWith(wrapper.vm.$refs.theForm); + expect(wrapper.vm.dispatchStoreAction).not.toHaveBeenCalled(); + expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled(); + }); + + test("saves and navigates when the form is valid", async () => { + const { wrapper } = setupMocks(); + wrapper.vm.isFormValid = jest.fn().mockResolvedValue(true); + + await wrapper.vm.forwardButtonAction(); + + expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalled(); + expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled(); + }); + }); + + describe("policy-number-format validation rule", () => { + test("passes when no PolicyNumberValidationWidget config is returned", async () => { + const { wrapper } = await setupMocksWithPolicyNumberValidationWidget(undefined); + + const result = await validate("AB1", "policy-number-format"); + + expect(result.valid).toBe(true); + expect(wrapper.vm).toBeTruthy(); + }); + + test("passes when field is empty (required rule handles that case)", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("", "policy-number-format"); + + expect(result.valid).toBe(true); + }); + + test("fails when value is shorter than minimumLength", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEF", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("fails when value is longer than maximumLength", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEFGHIJ", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("passes for a valid Alpha value within length bounds", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEFGH", "policy-number-format"); + + expect(result.valid).toBe(true); + }); + + test("fails for an Alpha rule when value contains digits", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEFG1", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("passes for a valid numeric value", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 6, + maximumLength: 10, + validationRuleType: "numeric", + }); + + const result = await validate("123456", "policy-number-format"); + + expect(result.valid).toBe(true); + }); + + test("fails for a numeric rule when value contains letters", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 6, + maximumLength: 10, + validationRuleType: "numeric", + }); + + const result = await validate("12345A", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("passes for a valid AlphaNumeric value", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 9, + maximumLength: 12, + validationRuleType: "AlphaNumeric", + }); + + const result = await validate("123456789a", "policy-number-format"); + + expect(result.valid).toBe(true); + }); + + test("fails for an AlphaNumeric rule when value contains a space", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 9, + maximumLength: 12, + validationRuleType: "AlphaNumeric", + }); + + const result = await validate("12345678 a", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("fails for an AlphaNumeric rule when value contains a dash", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 9, + maximumLength: 12, + validationRuleType: "AlphaNumeric", + }); + + const result = await validate("12345678-a", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("passes for a valid AlphaNumericWithSpaces value", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 6, + maximumLength: 12, + validationRuleType: "AlphaNumericWithSpaces", + }); + + const result = await validate("ABC 123", "policy-number-format"); + + expect(result.valid).toBe(true); + }); + + test("fails for an AlphaNumericWithSpaces rule when value contains other symbols", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 6, + maximumLength: 12, + validationRuleType: "AlphaNumericWithSpaces", + }); + + const result = await validate("ABC-123", "policy-number-format"); + + expect(result.valid).toBe(false); + }); + + test("uses the CMS-provided minValidationMessage when value is shorter than minimumLength", async () => { + const minValidationMessage = "Policy number should be at least 8 characters."; + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage, + maxValidationMessage: "Policy number must be 9 characters or less.", + }); + + const result = await validate("ABCDEF", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([minValidationMessage]); + }); + + test("uses the CMS-provided maxValidationMessage when value is longer than maximumLength", async () => { + const maxValidationMessage = "Policy number must be 9 characters or less."; + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage: "Policy number should be at least 8 characters.", + maxValidationMessage, + }); + + const result = await validate("ABCDEFGHIJ", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([maxValidationMessage]); + }); + + test("falls back to the default error message when minValidationMessage is not provided", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEF", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]); + }); + + test("falls back to the default error message when maxValidationMessage is not provided", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + }); + + const result = await validate("ABCDEFGHIJ", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]); + }); + + test("uses the CMS-provided invalidCharMessage for a pattern-only failure (value within length bounds)", async () => { + const invalidCharMessage = "Policy number may only contain letters."; + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage: "Policy number should be at least 8 characters.", + maxValidationMessage: "Policy number must be 9 characters or less.", + invalidCharMessage, + }); + + const result = await validate("ABCDEFG1", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([invalidCharMessage]); + }); + + test("falls back to the default error message for a pattern-only failure when invalidCharMessage is not provided", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage: "Policy number should be at least 8 characters.", + maxValidationMessage: "Policy number must be 9 characters or less.", + }); + + const result = await validate("ABCDEFG1", "policy-number-format"); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]); + }); + }); + + describe("policyNumber field end-to-end (via textbox-question)", () => { + // Regression test for a reported bug: typing a value that never passes through a + // "valid" intermediate state (e.g. "a@2" against an 8-9 char Alpha-only rule) left + // vee-validate's field `meta.valid` stuck at `true`, since textbox-question's value + // watcher only synced/validated the field when a pre-check said the value was valid. + // In the app this drives the Forward button's disabled state + // (:isForwardActionDisabled="!meta.valid"), so the bug meant a user could type an + // invalid policy number and the form would not flag it as invalid at all - it only + // "self-corrected" via the input's native `change` event on blur, which doesn't fire + // from keystrokes alone. This test dispatches only "input" events (like real typing) + // and intentionally does NOT fire a native "change"/blur event, to isolate the + // watcher's own behavior from that unrelated safety net. + test("marks the field invalid as soon as an unrecoverable value is typed, without waiting for blur", async () => { + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage: "Policy number should be at least 8 characters.", + maxValidationMessage: "Policy number must be 9 characters or less.", + }); + + const wrapper = mount(textboxQuestion, { + global: { + directives: { maska: jest.fn() }, + mixins: [ + { + methods: { + getCmsContent: jest.fn(() => ""), + }, + }, + ], + }, + props: { + // Start from a value that already satisfies the rule, so meta.valid + // begins as true (isolating the watcher's behavior from the + // required-on-empty check, which is independently already true on + // mount regardless of this bug). + modelValue: "ABCDEFGH", + inputId: "policyNumber", + validationRules: "policy-number-required|policy-number-format", + "onUpdate:modelValue": (newValue) => wrapper.setProps({ modelValue: newValue }), + }, + }); + await flushPromises(); + expect(wrapper.vm.meta.valid).toBe(true); + + const input = wrapper.find("input"); + input.element.value = "a@2"; + await input.trigger("input"); // simulate typing only, no blur/change + await flushPromises(); + + expect(wrapper.vm.meta.valid).toBe(false); + }); + + // Reproduces "I typed a@2 and clicked Continue, and the message never appeared". + // Clicking Continue submits the real
, which vee-validate intercepts and + // fully (re)validates every field using that field's own internally tracked value - + // not necessarily whatever is visible in the DOM input. If that internal value is + // stale (the underlying bug), the message can still fail to appear even though + // meta.valid correctly flips to false while typing. + test("shows the CMS validation message in the DOM after typing an invalid value and submitting the form", async () => { + const minValidationMessage = "Policy number should be at least 8 characters."; + await setupMocksWithPolicyNumberValidationWidget({ + minimumLength: 8, + maximumLength: 9, + validationRuleType: "Alpha", + minValidationMessage, + maxValidationMessage: "Policy number must be 9 characters or less.", + }); + + const harness = { + components: { Form, textboxQuestion }, + data() { + return { policyNumber: "ABCDEFGH" }; + }, + template: ` + + + + + `, + }; + + const wrapper = mount(harness, { + global: { + directives: { maska: jest.fn() }, + mixins: [ + { + methods: { + getCmsContent: jest.fn(() => ""), + }, + }, + ], + }, + }); + await flushPromises(); + + const input = wrapper.find("input"); + input.element.value = "a@2"; + await input.trigger("input"); // simulate typing only, no blur/change + await flushPromises(); + + // Mirrors clicking the real "Continue" button, which submits the
. + await wrapper.find("form").trigger("submit"); + await flushPromises(); + + expect(wrapper.text()).toContain(minValidationMessage); + }); + }); }); function setupMocks() { @@ -78,3 +466,30 @@ function setupMocks() { return { wrapper }; } + +// Runs beforeRouteEnter with a clientConfig that includes the given +// PolicyNumberValidationWidget config so the module-scoped rule config gets populated, +// mirroring how the real CMS response would flow through the route guard. Like +// ConfigWidget, the real CMS wraps the widget's config in a JSON-encoded `Text` +// string rather than returning a plain object, so we replicate that shape here too. +async function setupMocksWithPolicyNumberValidationWidget(policyNumberValidationConfig) { + const { wrapper } = setupMocks(); + + settleAllPromises.mockResolvedValue({ + cmsContent: {}, + clientConfig: { + PolicyNumberValidationWidget: policyNumberValidationConfig + ? { Text: JSON.stringify(policyNumberValidationConfig) } + : undefined, + }, + }); + + await policyInfo.beforeRouteEnter.call( + wrapper.vm, + { name: "policy-info" }, + undefined, + (callback) => callback(wrapper.vm) + ); + + return { wrapper }; +} diff --git a/src/layouts/policy-info/policy-info.vue b/src/layouts/policy-info/policy-info.vue index f12f38069..57b9b4e5e 100644 --- a/src/layouts/policy-info/policy-info.vue +++ b/src/layouts/policy-info/policy-info.vue @@ -21,7 +21,7 @@ :overrideQuestionText="policyNumberLabelOverride" v-model="policyNumber" inputId="policyNumber" - validationRules="policy-number-required" /> + validationRules="policy-number-required|policy-number-format" /> { + if (!value || !value.length || !policyNumberValidationConfig) { + return true; + } + + const { + minimumLength, + maximumLength, + validationRuleType, + minValidationMessage, + maxValidationMessage, + invalidCharMessage, + } = policyNumberValidationConfig; + + if (typeof minimumLength === "number" && value.length < minimumLength) { + return minValidationMessage || errorMessages.POLICY_NUMBER_FORMAT; + } + + if (typeof maximumLength === "number" && value.length > maximumLength) { + return maxValidationMessage || errorMessages.POLICY_NUMBER_FORMAT; + } + + const pattern = getPolicyNumberFormatPattern(validationRuleType); + if (pattern && !pattern.test(value)) { + return invalidCharMessage || errorMessages.POLICY_NUMBER_FORMAT; + } + + return true; +}); defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED)); defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT)); defineRule("phone-number-required", required(errorMessages.PHONE_REQUIRED)); @@ -412,6 +493,9 @@ export default { next((vm) => { vm.setCmsContent(resultMap.cmsContent); vm.clientConfig = resultMap.clientConfig ?? {}; + policyNumberValidationConfig = parsePolicyNumberValidationWidget( + vm.clientConfig?.PolicyNumberValidationWidget + ); // The load-session API can return damageCause in a different case than // the CMS answer values. Reconcile it so the dropdown pre-selects. vm.damageCause = vm.matchDamageCauseToOption(vm.damageCause); @@ -488,6 +572,17 @@ export default { ); }, async forwardButtonAction() { + // Explicitly (re)validate every field before navigating, rather than relying + // solely on isForwardActionDisabled/meta.valid already being accurate and on the + // Continue button's incidental native form-submission side effect to surface + // error messages. This guarantees the CMS-driven policy number format error (and + // any other field's error) is actually rendered when Continue is clicked while + // the form is invalid. + const isValid = await this.isFormValid(this.$refs.theForm); + if (!isValid) { + return; + } + this.dispatchStoreAction( this.storeActions.SAVE_INSURANCE_DETAILS, { diff --git a/src/layouts/policy-vehicle/policy-vehicle.vue b/src/layouts/policy-vehicle/policy-vehicle.vue index e03a9e766..ef2951d0f 100644 --- a/src/layouts/policy-vehicle/policy-vehicle.vue +++ b/src/layouts/policy-vehicle/policy-vehicle.vue @@ -43,7 +43,6 @@ import insuranceNavBar from "@/fmg-components/insurance-nav-bar/insurance-nav-ba import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header"; import store from "@/store"; import buttonMain from "@/ux-components/button-main/button-main"; -import { parentAccountNumbers } from "@/constants/insurance"; import { debugLog } from "@/helpers/debug-log-helper"; import { Form } from "vee-validate"; @@ -106,9 +105,6 @@ export default { displayDisclaimerText() { return this.disclaimerText !== null && this.disclaimerText !== ""; }, - parentAccountNumbers() { - return parentAccountNumbers; - }, }, methods: { diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index 788c93281..d64ffa779 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -2,6 +2,7 @@ import { shallowMount } from "@vue/test-utils"; import scheduling from "./scheduling"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { settleAllPromises } from "@/helpers/layout-helper"; +import store from "@/store"; jest.mock("@/store", () => ({ dispatch: jest.fn().mockResolvedValue(null), @@ -107,5 +108,72 @@ describe("scheduling.vue", () => { await expect(wrapper.vm.handleRequestMoreDates()).resolves.toBeUndefined(); wrapper.unmount(); }); + + test("batches inshop provider requests into a single getShopTimeSlotsV2 call", async () => { + store.dispatch.mockClear(); + settleAllPromises.mockResolvedValueOnce({ + inshopTimeSlots: { + providerTimeSlots: [ + { providerNumber: "05018", days: [{ date: "2026-07-22", timeSlots: [] }] }, + { providerNumber: "05019", days: [{ date: "2026-07-22", timeSlots: [] }] }, + ], + }, + }); + + const { wrapper } = setupMocks(); + wrapper.vm.inShopProvidersAndTimeslots = [ + { provider: { providerNumber: "05018" }, timeSlots: { days: [] } }, + { provider: { providerNumber: "05019" }, timeSlots: { days: [] } }, + ]; + wrapper.vm.datePickerEndDate = "2026-07-21"; + + await wrapper.vm.handleRequestMoreDates(); + + const shopTimeSlotDispatches = store.dispatch.mock.calls.filter( + ([actionName]) => actionName === "getShopTimeSlotsV2" + ); + expect(shopTimeSlotDispatches).toHaveLength(1); + expect(shopTimeSlotDispatches[0][1].payload.providerNumbers).toEqual([ + "05018", + "05019", + ]); + expect(shopTimeSlotDispatches[0][1].payload.endDate).toBeDefined(); + wrapper.unmount(); + }); + + test("maps batched inshop providerTimeSlots back onto provider entries", async () => { + settleAllPromises.mockResolvedValueOnce({ + inshopTimeSlots: { + providerTimeSlots: [ + { + providerNumber: "05018", + days: [{ date: "2026-07-22", timeSlots: [{ id: "slot-a" }] }], + }, + { + providerNumber: "05019", + days: [{ date: "2026-07-22", timeSlots: [{ id: "slot-b" }] }], + }, + ], + }, + }); + + const { wrapper } = setupMocks(); + wrapper.vm.inShopProvidersAndTimeslots = [ + { provider: { providerNumber: "05018" }, timeSlots: { days: [] } }, + { provider: { providerNumber: "05019" }, timeSlots: { days: [] } }, + ]; + wrapper.vm.datePickerEndDate = "2026-07-21"; + + await wrapper.vm.handleRequestMoreDates(); + + expect(wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days).toHaveLength(1); + expect( + wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days[0].timeSlots[0].id + ).toBe("slot-a"); + expect( + wrapper.vm.inShopProvidersAndTimeslots[1].timeSlots.days[0].timeSlots[0].id + ).toBe("slot-b"); + wrapper.unmount(); + }); }); }); diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 7fa45f056..3ace9edf7 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -111,16 +111,45 @@ function appendDays(entry, newSlots) { } /** - * Returns a promise for inshop time slots for a single provider. - * @param {{ startDate: string, endDate: string, providerNumber: string, pageNameToLog: string }} params + * Assigns time slots from a v2 multi-provider response onto in-shop provider entries. + * @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries + * @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[], provisionalTriggers?: string[] }> } | null | undefined} multiProviderResponse */ -function fetchInshopTimeSlots({ startDate, endDate, providerNumber, pageNameToLog }) { - return store.dispatch("getShopTimeSlots", { +function assignInshopTimeSlotsFromV2Response(entries, multiProviderResponse) { + const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? []; + entries.forEach((entry) => { + entry.timeSlots = + providerTimeSlots.find((pts) => pts.providerNumber === entry.provider.providerNumber) ?? + null; + }); +} + +/** + * Appends days from a v2 multi-provider response onto existing in-shop provider entries. + * @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries + * @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[] }> } | null | undefined} multiProviderResponse + */ +function appendInshopTimeSlotsFromV2Response(entries, multiProviderResponse) { + const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? []; + entries.forEach((entry) => { + const newSlots = providerTimeSlots.find( + (pts) => pts.providerNumber === entry.provider.providerNumber + ); + appendDays(entry, newSlots); + }); +} + +/** + * Returns a promise for inshop time slots for multiple providers. + * @param {{ startDate: string, endDate: string, providerNumbers: string[], pageNameToLog: string }} params + */ +function fetchInshopTimeSlots({ startDate, endDate, providerNumbers, pageNameToLog }) { + return store.dispatch("getShopTimeSlotsV2", { payload: { startDate, endDate, shopAppointmentType: "InshopOrDropoff", - providerNumber, + providerNumbers, }, pageNameToLog, }); @@ -131,8 +160,12 @@ function fetchInshopTimeSlots({ startDate, endDate, providerNumber, pageNameToLo * @param {{ startDate: string, endDate: string, zipCode: string, pageNameToLog: string }} params */ function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) { - return store.dispatch("getMobileTimeSlots", { - payload: { startDate, endDate, zipCode }, + return store.dispatch("getMobileTimeSlotsV2", { + payload: { + startDate, + endDate, + zipCode, + }, pageNameToLog, }); } @@ -176,16 +209,21 @@ export default { : null; const startDate = toDateString(0); const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1); + const providerNumbers = providers.map((provider) => provider.providerNumber); const timeSlotsPromiseResultMap = [ - ...providers.map((provider, i) => ({ - resultKey: `inshopTimeSlots_${i}`, - promise: fetchInshopTimeSlots({ - startDate, - endDate, - providerNumber: provider.providerNumber, - pageNameToLog: to.name, - }), - })), + ...(providerNumbers.length + ? [ + { + resultKey: "inshopTimeSlots", + promise: fetchInshopTimeSlots({ + startDate, + endDate, + providerNumbers, + pageNameToLog: to.name, + }), + }, + ] + : []), // Get the mobile time slots if a mobile provider number is available ...(mobileProviderNumber ? [ @@ -202,9 +240,10 @@ export default { : []), ]; const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap); - vm.inShopProvidersAndTimeslots.forEach((entry, i) => { - entry.timeSlots = timeSlotsResultMap[`inshopTimeSlots_${i}`] ?? null; - }); + assignInshopTimeSlotsFromV2Response( + vm.inShopProvidersAndTimeslots, + timeSlotsResultMap.inshopTimeSlots + ); if (vm.mobileProviderAndTimeSlot) { vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null; } @@ -332,17 +371,24 @@ export default { const newStartDate = toDateString(1, this.datePickerEndDate); const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate); + const providerNumbers = this.inShopProvidersAndTimeslots.map( + ({ provider }) => provider.providerNumber + ); const promiseResultMap = [ - ...this.inShopProvidersAndTimeslots.map(({ provider }, i) => ({ - resultKey: `inshopTimeSlots_${i}`, - promise: fetchInshopTimeSlots({ - startDate: newStartDate, - endDate: newEndDate, - providerNumber: provider.providerNumber, - pageNameToLog: this.pageName, - }), - })), + ...(providerNumbers.length + ? [ + { + resultKey: "inshopTimeSlots", + promise: fetchInshopTimeSlots({ + startDate: newStartDate, + endDate: newEndDate, + providerNumbers, + pageNameToLog: this.pageName, + }), + }, + ] + : []), ...(this.mobileProviderAndTimeSlot ? [ { @@ -360,10 +406,10 @@ export default { const resultMap = await settleAllPromises(promiseResultMap); - // Provider order is fixed after beforeRouteEnter, so index keys are stable. - this.inShopProvidersAndTimeslots.forEach((entry, index) => { - appendDays(entry, resultMap[`inshopTimeSlots_${index}`]); - }); + appendInshopTimeSlotsFromV2Response( + this.inShopProvidersAndTimeslots, + resultMap.inshopTimeSlots + ); if (this.mobileProviderAndTimeSlot) { appendDays(this.mobileProviderAndTimeSlot, resultMap.mobileTimeSlots); diff --git a/src/store/index.js b/src/store/index.js index 76e98dd4f..2fff59ee3 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2543,6 +2543,149 @@ export const actions = { return globalMethods.callHttpClient(options); }, + getShopTimeSlotsV2( + context, + { payload: { startDate, endDate, shopAppointmentType, providerNumbers }, pageNameToLog } + ) { + const order = context.state.order; + const vehicle = context.state.order.vehicle; + const payment = context.state.order.payment; + + const lineItems = getFlattenedLineItemsWithGlassPartTag(order.lineItems); + + const glassPieces = order.damage.glassToReplace + ? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace) + : []; + var payload = { + providerNumbers: providerNumbers, + startDate: startDate, + endDate: endDate, + shopAppointmentType: shopAppointmentType, + applicationName: applicationConfig.APPLICATION_NAME, + parentAccountNumber: payment.parentAccountNumber, + carId: vehicle.carId, + lineItems: lineItems, + glassPieces: glassPieces, + eon: order.eon, + billToAccountNumber: context.getters.payment.billToAccountNumber, + coverage: { + status: payment.insuranceCoverage.coverageStatus, + deductible: order.policy.currentDeductible, + additionalAuthFlag: order.policy.additionalAuthFlag, + }, + partSelection: { + hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length, + hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length, + hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length, + hasManuallySelectedParts: + !!context.state.applicationUser.pageData["vehicle-parts"]?.partsOrQuestions + .length, + }, + vehicle: { + year: vehicle.year, + make: vehicle.make, + model: vehicle.model, + style: vehicle.style, + vin: vehicle.vin ?? "", + }, + }; + + let hasCalled = timeSlotCallFlags.shopV2; + if (!hasCalled) { + timeSlotCallFlags.shopV2 = true; + } + + const options = { + method: endpoints.GetShopTimeSlotsV2.method, + endpoint: endpoints.GetShopTimeSlotsV2.url, + payload: payload, + logApiCall: true, + pageNameToLog: pageNameToLog, + }; + + // Only set handler if this is the very first call in this session + if (!hasCalled) { + options.additionalSuccessEventDataHandler = (response) => { + const firstProviderTimeSlots = response.data.providerTimeSlots?.[0]; + return getTimeSlotsAdditionalEventData( + firstProviderTimeSlots?.provisionalTriggers, + order.serviceLocation.zipCode, + firstProviderTimeSlots?.days?.[0]?.date, + shopAppointmentType + ); + }; + } + + return globalMethods.callHttpClient(options); + }, + + getMobileTimeSlotsV2(context, { payload: { startDate, endDate, zipCode }, pageNameToLog }) { + const order = context.state.order; + const vehicle = context.state.order.vehicle; + const payment = context.state.order.payment; + const lineItems = getFlattenedLineItemsWithGlassPartTag(order.lineItems); + const glassPieces = order.damage.glassToReplace + ? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace) + : []; + var payload = { + startDate: startDate, + endDate: endDate, + applicationName: applicationConfig.APPLICATION_NAME, + parentAccountNumber: payment.parentAccountNumber, + carId: vehicle.carId, + lineItems: lineItems, + glassPieces: glassPieces, + eon: order.eon, + billToAccountNumber: context.getters.payment.billToAccountNumber, + coverage: { + status: payment.insuranceCoverage.coverageStatus, + deductible: order.policy.currentDeductible, + additionalAuthFlag: order.policy.additionalAuthFlag, + }, + partSelection: { + hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length, + hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length, + hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length, + hasManuallySelectedParts: + !!context.state.applicationUser.pageData["vehicle-parts"]?.partsOrQuestions + .length, + }, + vehicle: { + year: vehicle.year, + make: vehicle.make, + model: vehicle.model, + style: vehicle.style, + vin: vehicle.vin ?? "", + }, + zipCode: zipCode ?? order.serviceLocation.zipCode, + }; + + let hasCalled = timeSlotCallFlags.mobileV2; + if (!hasCalled) { + timeSlotCallFlags.mobileV2 = true; + } + + const options = { + method: endpoints.GetMobileTimeSlotsV2.method, + endpoint: endpoints.GetMobileTimeSlotsV2.url, + payload: payload, + logApiCall: true, + pageNameToLog: pageNameToLog, + }; + + // Only set handler if this is the very first call in this session + if (!hasCalled) { + options.additionalSuccessEventDataHandler = (response) => + getTimeSlotsAdditionalEventData( + response.data.provisionalTriggers, + order.serviceLocation.zipCode, + response.data.days?.[0]?.date + ); + } + + return globalMethods.callHttpClient(options); + }, + getMobilePremiumFee(context, { pageNameToLog }) { const damageType = context.getters.damage.isRepair ? "Repair" : "Replace"; const paymentType = context.getters.order.payment.isInsurance ? "Insurance" : "Cash"; @@ -4530,6 +4673,8 @@ function saveExternalParameterState(externalParameterState) { const timeSlotCallFlags = { shop: false, mobile: false, + shopV2: false, + mobileV2: false, }; function checkIfMiscParts(partsOrQuestions) { diff --git a/src/store/store.spec.js b/src/store/store.spec.js index 860b81718..1753ed621 100644 --- a/src/store/store.spec.js +++ b/src/store/store.spec.js @@ -3,6 +3,7 @@ import store from "@/store"; import { mutations, state, actions, getters } from "@/store"; import { storeMutations } from "@/constants/store-mutations"; import { storeActions } from "@/constants/store-actions"; +import { endpoints } from "@/constants/endpoints.js"; import { sessionStorageKeyConstants } from "@/constants/session-storage"; import { experimentTriggers } from "@/constants/experiments"; import { routeData } from "@/router/constants/routes"; @@ -3631,6 +3632,71 @@ describe("Actions", () => { ); }); }); + + describe("v2 time slot actions", () => { + const buildTimeSlotContext = () => ({ + state, + getters: { + payment: { billToAccountNumber: "87291" }, + }, + }); + + it("getShopTimeSlotsV2 calls v2 shop endpoint with providerNumbers and endDate", async () => { + const context = buildTimeSlotContext(); + + globalMethods.callHttpClient.mockResolvedValue({ data: { providerTimeSlots: [] } }); + + await actions.getShopTimeSlotsV2(context, { + payload: { + startDate: "2026-07-07", + endDate: "2026-07-21", + shopAppointmentType: "InshopOrDropoff", + providerNumbers: ["05018", "05019"], + }, + pageNameToLog: "scheduling", + }); + + expect(globalMethods.callHttpClient).toHaveBeenCalledWith( + expect.objectContaining({ + method: endpoints.GetShopTimeSlotsV2.method, + endpoint: endpoints.GetShopTimeSlotsV2.url, + payload: expect.objectContaining({ + startDate: "2026-07-07", + endDate: "2026-07-21", + providerNumbers: ["05018", "05019"], + shopAppointmentType: "InshopOrDropoff", + }), + }) + ); + }); + + it("getMobileTimeSlotsV2 calls v2 mobile endpoint with endDate", async () => { + const context = buildTimeSlotContext(); + + globalMethods.callHttpClient.mockResolvedValue({ data: { days: [] } }); + + await actions.getMobileTimeSlotsV2(context, { + payload: { + startDate: "2026-07-07", + endDate: "2026-07-21", + zipCode: "43235", + }, + pageNameToLog: "scheduling", + }); + + expect(globalMethods.callHttpClient).toHaveBeenCalledWith( + expect.objectContaining({ + method: endpoints.GetMobileTimeSlotsV2.method, + endpoint: endpoints.GetMobileTimeSlotsV2.url, + payload: expect.objectContaining({ + startDate: "2026-07-07", + endDate: "2026-07-21", + zipCode: "43235", + }), + }) + ); + }); + }); }); describe("submittedStateRevision", () => {