From b437ca183ef851ec1b0eafbbae0f61120d44ac92 Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Mon, 6 Jul 2026 08:12:52 -0400 Subject: [PATCH] CASH-2978 added policy number validation CASH-2978 added policy number validation --- src/constants/error-messages.js | 1 + .../textbox-question/textbox-question.spec.js | 41 +- .../textbox-question/textbox-question.vue | 12 +- .../coverage-statement/coverage-statement.vue | 4 +- .../insurance-details/insurance-details.vue | 10 +- src/layouts/policy-info/policy-info.spec.js | 417 +++++++++++++++++- src/layouts/policy-info/policy-info.vue | 97 +++- 7 files changed, 560 insertions(+), 22 deletions(-) diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index c39b2db80..2f49e2f1b 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -41,6 +41,7 @@ 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", 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.vue b/src/layouts/coverage-statement/coverage-statement.vue index db915ae80..e39f4a65b 100644 --- a/src/layouts/coverage-statement/coverage-statement.vue +++ b/src/layouts/coverage-statement/coverage-statement.vue @@ -207,8 +207,8 @@ export default { arePagePrerequisitesValid() { const order = store.getters.order; if (order.payment.isClaimAndCoverage) { - return true; //TODO: Future card when C&C flow is implemented - } + return true; //TODO: Future card when C&C flow is implemented + } const logQueue = []; diff --git a/src/layouts/insurance-details/insurance-details.vue b/src/layouts/insurance-details/insurance-details.vue index e53b08ece..6142b86d1 100644 --- a/src/layouts/insurance-details/insurance-details.vue +++ b/src/layouts/insurance-details/insurance-details.vue @@ -172,14 +172,8 @@ defineRule("city-required", required(errorMessages.CITY_REQUIRED)); defineRule("state-required", required(errorMessages.STATE_REQUIRED)); defineRule("zip-code-required", required(errorMessages.ZIP_CODE_REQUIRED)); defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_CODE_FORMAT)); -defineRule( - "claim-number-numeric", - regex(/^\d+$/, errorMessages.CLAIM_NUMBER_VALID) -); -defineRule( - "claim-number-format", - regex(/^\d{6,8}$/, errorMessages.CLAIM_NUMBER_FORMAT) -); +defineRule("claim-number-numeric", regex(/^\d+$/, errorMessages.CLAIM_NUMBER_VALID)); +defineRule("claim-number-format", regex(/^\d{6,8}$/, errorMessages.CLAIM_NUMBER_FORMAT)); export default { name: "insurance-details", 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, {