Merge branch 'release/2026.06.18' into feature/CASH-2856
This commit is contained in:
commit
429961d94a
17 changed files with 756 additions and 26 deletions
|
|
@ -258,10 +258,36 @@ body {
|
||||||
return match?.value;
|
return match?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refactor opportunity: sessionStorage keys and cookie names below are duplicated from the
|
||||||
|
// Vue app because this static page is not webpack-bundled. Canonical sources:
|
||||||
|
// - src/constants/session-storage.js (sessionStorageKeyConstants)
|
||||||
|
// - src/constants/cookie-names.js (cookieNames; env-suffixed funnel cookies)
|
||||||
|
// - src/router/methods/error.js (clearBailoutSessionStorage — keep in sync on bailout keys)
|
||||||
|
// Options: build-time inject into this file, shared plain JS under public/static/, or generate
|
||||||
|
// from constants during CI. Until then, update all three places when keys change.
|
||||||
function clearApplicationData() {
|
function clearApplicationData() {
|
||||||
window.localStorage.removeItem('vuex');
|
window.localStorage.removeItem('vuex');
|
||||||
|
|
||||||
|
// Keys match sessionStorageKeyConstants in src/constants/session-storage.js and
|
||||||
|
// clearBailoutSessionStorage() in src/router/methods/error.js — keep in sync.
|
||||||
|
const sessionStorageKeysToClear = [
|
||||||
|
'submittedState',
|
||||||
|
'heritageSuppressRedirectCount',
|
||||||
|
'beforeEachErrorRecoveryCount',
|
||||||
|
'externalParameterState',
|
||||||
|
];
|
||||||
|
// Clear sessionStorage keys that can re-poison the funnel on re-entry.
|
||||||
|
sessionStorageKeysToClear.forEach((key) => {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const environmentData = getCurrentEnvironmentData();
|
const environmentData = getCurrentEnvironmentData();
|
||||||
|
// Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here
|
||||||
|
// (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app).
|
||||||
const cookieNames = [
|
const cookieNames = [
|
||||||
`FunnelUserId-${environmentData?.name}`,
|
`FunnelUserId-${environmentData?.name}`,
|
||||||
`FunnelSessionKey-${environmentData?.name}`,
|
`FunnelSessionKey-${environmentData?.name}`,
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,8 @@ export const sessionStorageKeyConstants = {
|
||||||
SUBMITTED_STATE: "submittedState",
|
SUBMITTED_STATE: "submittedState",
|
||||||
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
|
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
|
||||||
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
|
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
|
||||||
|
/** Counts beforeEach uncaught-exception recovery attempts to avoid an infinite ERROR redirect loop. */
|
||||||
|
BEFORE_EACH_ERROR_RECOVERY_COUNT: "beforeEachErrorRecoveryCount",
|
||||||
|
/** Persists deep-link / querystring funnel entry state across navigations. */
|
||||||
|
EXTERNAL_PARAMETER_STATE: "externalParameterState",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,15 @@ export default {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No VINS found.
|
// No VINS found.
|
||||||
this.displayVinNotFoundAlert = true;
|
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||||
|
if (isVinLookupRequired) {
|
||||||
|
this.continueWithFailedLookup(
|
||||||
|
this.customerQuestions,
|
||||||
|
resultMap.serviceZipValidationResponse
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.displayVinNotFoundAlert = true;
|
||||||
|
}
|
||||||
return this.$refs.navbar.removeLoader();
|
return this.$refs.navbar.removeLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,6 +470,28 @@ export default {
|
||||||
this.displayVinLookupByHomeAddressNotAllowedAlert = false;
|
this.displayVinLookupByHomeAddressNotAllowedAlert = false;
|
||||||
this.displayNoServiceAlert = false;
|
this.displayNoServiceAlert = false;
|
||||||
},
|
},
|
||||||
|
async continueWithFailedLookup(customerQuestions, zipCodeValidationResponse) {
|
||||||
|
// Save the entered customer questions and service zip code (if applicable) so that it can be used in the next page if needed
|
||||||
|
await this.dispatchStoreAction(
|
||||||
|
this.storeActions.SAVE_CUSTOMER_DETAILS,
|
||||||
|
{
|
||||||
|
firstName: customerQuestions.firstName,
|
||||||
|
lastName: customerQuestions.lastName,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
await this.dispatchStoreAction(
|
||||||
|
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||||
|
{
|
||||||
|
state: zipCodeValidationResponse.state,
|
||||||
|
zipCode: this.serviceZipCode || this.customerQuestions.addressQuestions.zipCode,
|
||||||
|
zipCodeCtu: zipCodeValidationResponse.zipCodeCtu,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
this.navigateForwardWithSingleCarMatch();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.attachCustomEvents();
|
this.attachCustomEvents();
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,12 @@ export default {
|
||||||
resultMap.registrationZipValidationResponse.state
|
resultMap.registrationZipValidationResponse.state
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
// No VIN found.
|
// No VIN found.
|
||||||
this.displayVinNotFoundAlert = true;
|
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||||
|
if (isVinLookupRequired) {
|
||||||
|
this.continueWithFailedLookup(resultMap.serviceZipValidationResponse);
|
||||||
|
} else {
|
||||||
|
this.displayVinNotFoundAlert = true;
|
||||||
|
}
|
||||||
return this.$refs.navbar.removeLoader();
|
return this.$refs.navbar.removeLoader();
|
||||||
});
|
});
|
||||||
// If no VIN was found, stop processing after displaying alert
|
// If no VIN was found, stop processing after displaying alert
|
||||||
|
|
@ -393,6 +398,18 @@ export default {
|
||||||
this.displayInvalidZipAlert = false;
|
this.displayInvalidZipAlert = false;
|
||||||
this.displayNoServiceAlert = false;
|
this.displayNoServiceAlert = false;
|
||||||
},
|
},
|
||||||
|
async continueWithFailedLookup(zipCodeData) {
|
||||||
|
await this.dispatchStoreAction(
|
||||||
|
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||||
|
{
|
||||||
|
state: zipCodeData.state,
|
||||||
|
zipCode: this.serviceZipCode || this.registrationZipCode,
|
||||||
|
zipCodeCtu: zipCodeData.zipCodeCtu,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
this.navigateForwardWithSingleCarMatch();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.attachCustomEvents();
|
this.attachCustomEvents();
|
||||||
|
|
|
||||||
|
|
@ -567,14 +567,14 @@ export default {
|
||||||
|
|
||||||
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
|
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
|
||||||
|
|
||||||
const shouldUseAdyen = experimentMixin.methods.hasSettingEqualTo(
|
const shouldUseCybersource = experimentMixin.methods.hasSettingEqualTo(
|
||||||
experimentSettings.USE_ADYEN_PAYMENT,
|
experimentSettings.USE_ADYEN_PAYMENT,
|
||||||
"true"
|
"false"
|
||||||
);
|
);
|
||||||
|
|
||||||
const scenarioName = shouldUseAdyen
|
const scenarioName = shouldUseCybersource
|
||||||
? this.navigationScenarios.CLICKED_PAY_NOW_ADYEN
|
? this.navigationScenarios.CLICKED_PAY_NOW
|
||||||
: this.navigationScenarios.CLICKED_PAY_NOW;
|
: this.navigationScenarios.CLICKED_PAY_NOW_ADYEN;
|
||||||
|
|
||||||
this.$router.navigateWithoutSaving(scenarioName, this.pageName);
|
this.$router.navigateWithoutSaving(scenarioName, this.pageName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,20 @@ jest.mock("@/store", () => ({
|
||||||
commit: jest.fn(),
|
commit: jest.fn(),
|
||||||
dispatch: jest.fn(),
|
dispatch: jest.fn(),
|
||||||
getters: {
|
getters: {
|
||||||
|
applicationUser: {
|
||||||
|
bailoutCode: null,
|
||||||
|
},
|
||||||
|
externalParameterState: {
|
||||||
|
isExternalParameter: false,
|
||||||
|
},
|
||||||
|
emailOrSms: "email",
|
||||||
vehicle: {
|
vehicle: {
|
||||||
year: 2019,
|
year: 2019,
|
||||||
carId: "C00000",
|
carId: "C00000",
|
||||||
|
vin: null,
|
||||||
|
vinRequired: false,
|
||||||
|
isBigTruck: false,
|
||||||
|
canSafeliteService: true,
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
serviceLocation: {
|
serviceLocation: {
|
||||||
|
|
@ -23,6 +34,11 @@ jest.mock("@/store", () => ({
|
||||||
emailAddress: "builddigitaltest@safelite.com",
|
emailAddress: "builddigitaltest@safelite.com",
|
||||||
},
|
},
|
||||||
referralNumber: "666666",
|
referralNumber: "666666",
|
||||||
|
vehicle: {
|
||||||
|
make: null,
|
||||||
|
model: null,
|
||||||
|
year: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
payment: {
|
payment: {
|
||||||
insuranceCoverage: {
|
insuranceCoverage: {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,8 @@
|
||||||
vinPopulatedOnPageLoad &&
|
vinPopulatedOnPageLoad &&
|
||||||
!isInsuranceVerified &&
|
!isInsuranceVerified &&
|
||||||
!displayInvalidZipAlert &&
|
!displayInvalidZipAlert &&
|
||||||
!displayNonServiceableZipAlert
|
!displayNonServiceableZipAlert &&
|
||||||
|
!requiredVinNotFound
|
||||||
"
|
"
|
||||||
alertClass="alert-success" />
|
alertClass="alert-success" />
|
||||||
|
|
||||||
|
|
@ -144,6 +145,7 @@ import baseMixin from "@/mixins/base-mixin.js";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import vinPagesMixin from "@/mixins/vin-pages-mixin";
|
import vinPagesMixin from "@/mixins/vin-pages-mixin";
|
||||||
import { routeData } from "@/router/constants/routes";
|
import { routeData } from "@/router/constants/routes";
|
||||||
|
import { bailoutCodes } from "@/constants/bailout-codes";
|
||||||
|
|
||||||
// DEFINE VALIDATION RULES
|
// DEFINE VALIDATION RULES
|
||||||
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
|
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
|
||||||
|
|
@ -202,6 +204,7 @@ export default {
|
||||||
displayMatchedDifferentVehicleAlert: false,
|
displayMatchedDifferentVehicleAlert: false,
|
||||||
displayVinScanFailedAlert: false,
|
displayVinScanFailedAlert: false,
|
||||||
displayNoServiceAlert: false,
|
displayNoServiceAlert: false,
|
||||||
|
requiredVinNotFound: this.getRequiredVinNotFound(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -289,7 +292,13 @@ export default {
|
||||||
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
|
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
|
||||||
// If the vehicle result is undefined, the vin entered was invalid.
|
// If the vehicle result is undefined, the vin entered was invalid.
|
||||||
if (!resultMap.vehicleLookupResponse) {
|
if (!resultMap.vehicleLookupResponse) {
|
||||||
this.displayVinNotFoundAlert = true;
|
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||||
|
if (isVinLookupRequired) {
|
||||||
|
this.requiredVinNotFound = true;
|
||||||
|
this.continueWithFailedVin(this.vin, resultMap.zipCodeData);
|
||||||
|
} else {
|
||||||
|
this.displayVinNotFoundAlert = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Service Zip entered is serviceable, if not display an alert
|
// Check if Service Zip entered is serviceable, if not display an alert
|
||||||
|
|
@ -303,18 +312,18 @@ export default {
|
||||||
|
|
||||||
// Check if the CarId is different from the lookup vs what is in state currently.
|
// Check if the CarId is different from the lookup vs what is in state currently.
|
||||||
this.isCarIdDifferent =
|
this.isCarIdDifferent =
|
||||||
resultMap.vehicleLookupResponse.carId !== this.$store.getters.vehicle.carId;
|
resultMap.vehicleLookupResponse?.carId !== this.$store.getters.vehicle.carId;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isCarIdDifferent &&
|
this.isCarIdDifferent &&
|
||||||
resultMap.vehicleLookupResponse.carId !== this.previouslyEnteredCarId
|
resultMap.vehicleLookupResponse?.carId !== this.previouslyEnteredCarId
|
||||||
) {
|
) {
|
||||||
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse.carId;
|
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse?.carId;
|
||||||
this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse;
|
this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse;
|
||||||
this.displayMatchedDifferentVehicleAlert = true;
|
this.displayMatchedDifferentVehicleAlert = true;
|
||||||
|
|
||||||
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
|
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
|
||||||
resultMap.vehicleLookupResponse.carId,
|
resultMap.vehicleLookupResponse?.carId,
|
||||||
"vin-lookup"
|
"vin-lookup"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -486,10 +495,47 @@ export default {
|
||||||
this.displayVinNotFoundAlert = false;
|
this.displayVinNotFoundAlert = false;
|
||||||
this.displayVinScanFailedAlert = false;
|
this.displayVinScanFailedAlert = false;
|
||||||
this.displayNoServiceAlert = false;
|
this.displayNoServiceAlert = false;
|
||||||
|
this.requiredVinNotFound = false;
|
||||||
|
},
|
||||||
|
async continueWithFailedVin(vin, zipCodeData) {
|
||||||
|
var selectedVehicle = {
|
||||||
|
vin: vin,
|
||||||
|
vehicle: this.$store.getters.vehicle,
|
||||||
|
};
|
||||||
|
await this.dispatchStoreAction(
|
||||||
|
storeActions.SAVE_VIN,
|
||||||
|
{
|
||||||
|
vehicleInfo: Object.assign(selectedVehicle.vehicle, {
|
||||||
|
vin: vin,
|
||||||
|
}),
|
||||||
|
isSelectedGlassAvailableForVehicle: this.isSelectedGlassAvailableForVehicle,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
await this.dispatchStoreAction(
|
||||||
|
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||||
|
{
|
||||||
|
state: zipCodeData.state,
|
||||||
|
zipCode: this.serviceZipCode,
|
||||||
|
zipCodeCtu: zipCodeData.zipCodeCtu,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
this.navigateForwardWithSingleCarMatch();
|
||||||
|
},
|
||||||
|
getRequiredVinNotFound() {
|
||||||
|
// Prevents success alert from showing
|
||||||
|
var bailoutCode = this.$store.getters.applicationUser.bailoutCode;
|
||||||
|
var vinRequired = this.$store.getters.vehicle.vinRequired;
|
||||||
|
if (vinRequired && this.vin && bailoutCode == bailoutCodes.PART_NOT_FOUND) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.attachCustomEvents();
|
this.attachCustomEvents();
|
||||||
|
this.requiredVinNotFound = this.getRequiredVinNotFound();
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
AlertMatchedDifferentVehicleHeader() {
|
AlertMatchedDifferentVehicleHeader() {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ export default {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.PartNotFound) {
|
if (result.PartNotFound) {
|
||||||
bailoutMixin.methods.navigateToBailoutPage(this, bailoutCodes.PART_NOT_FOUND);
|
return bailoutMixin.methods.navigateToBailoutPage(
|
||||||
|
this,
|
||||||
|
bailoutCodes.PART_NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsOrQuestions = result.data.partsOrQuestions;
|
const partsOrQuestions = result.data.partsOrQuestions;
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,10 @@ const routingTable = function () {
|
||||||
scenario: navigationScenarios.CLICKED_VIN_RETRY,
|
scenario: navigationScenarios.CLICKED_VIN_RETRY,
|
||||||
destinationPageData: routeData.ESTIMATE,
|
destinationPageData: routeData.ESTIMATE,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: navigationScenarios.BAILOUT,
|
||||||
|
destinationPageData: routeData.BAILOUT,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
51
src/router/methods/before-each-error-recovery.js
Normal file
51
src/router/methods/before-each-error-recovery.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
|
|
||||||
|
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||||
|
|
||||||
|
/** Used when sessionStorage is missing, throws, or does not persist (e.g. some bots). */
|
||||||
|
let inMemoryRecoveryCount = 0;
|
||||||
|
|
||||||
|
export function getBeforeEachErrorRecoveryCount() {
|
||||||
|
try {
|
||||||
|
const stored = window.sessionStorage?.getItem(recoveryKey);
|
||||||
|
if (stored !== null && stored !== undefined) {
|
||||||
|
return Number(stored) || 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable; use in-memory fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return inMemoryRecoveryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementBeforeEachErrorRecoveryCount() {
|
||||||
|
const nextCount = getBeforeEachErrorRecoveryCount() + 1;
|
||||||
|
inMemoryRecoveryCount = nextCount;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage?.setItem(recoveryKey, String(nextCount));
|
||||||
|
} catch {
|
||||||
|
// in-memory count already updated
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBeforeEachErrorRecoveryCount() {
|
||||||
|
inMemoryRecoveryCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage?.removeItem(recoveryKey);
|
||||||
|
} catch {
|
||||||
|
// in-memory count already cleared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBeforeEachErrorRecoveryBeenAttempted() {
|
||||||
|
return getBeforeEachErrorRecoveryCount() >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: resets in-memory and sessionStorage recovery state between unit tests. */
|
||||||
|
export function resetBeforeEachErrorRecoveryStateForTests() {
|
||||||
|
clearBeforeEachErrorRecoveryCount();
|
||||||
|
}
|
||||||
80
src/router/methods/before-each-error-recovery.spec.js
Normal file
80
src/router/methods/before-each-error-recovery.spec.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
clearBeforeEachErrorRecoveryCount,
|
||||||
|
getBeforeEachErrorRecoveryCount,
|
||||||
|
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||||
|
incrementBeforeEachErrorRecoveryCount,
|
||||||
|
resetBeforeEachErrorRecoveryStateForTests,
|
||||||
|
} from "./before-each-error-recovery";
|
||||||
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
|
|
||||||
|
describe("router/methods/before-each-error-recovery", () => {
|
||||||
|
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||||
|
let originalSessionStorage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetBeforeEachErrorRecoveryStateForTests();
|
||||||
|
originalSessionStorage = window.sessionStorage;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalSessionStorage,
|
||||||
|
});
|
||||||
|
resetBeforeEachErrorRecoveryStateForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and writes recovery count via sessionStorage when available", () => {
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||||
|
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears sessionStorage and in-memory count", () => {
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
clearBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||||
|
expect(getBeforeEachErrorRecoveryCount()).toBe(0);
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses in-memory count when sessionStorage getItem throws", () => {
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("sessionStorage blocked");
|
||||||
|
},
|
||||||
|
setItem: jest.fn(),
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
expect(getBeforeEachErrorRecoveryCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses in-memory count when sessionStorage setItem throws", () => {
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("sessionStorage blocked");
|
||||||
|
},
|
||||||
|
removeItem: jest.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,7 +9,16 @@ import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
|
||||||
import { runExperiments } from "@/router/methods/helpers/run-experiments";
|
import { runExperiments } from "@/router/methods/helpers/run-experiments";
|
||||||
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
||||||
|
|
||||||
import { handleSoftError, handleHardError } from "@/router/methods/error";
|
import {
|
||||||
|
handleSoftError,
|
||||||
|
handleHardError,
|
||||||
|
redirectToStaticErrorPage,
|
||||||
|
} from "@/router/methods/error";
|
||||||
|
import {
|
||||||
|
clearBeforeEachErrorRecoveryCount,
|
||||||
|
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||||
|
incrementBeforeEachErrorRecoveryCount,
|
||||||
|
} from "@/router/methods/before-each-error-recovery";
|
||||||
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
|
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
|
||||||
import { checkLogParam } from "@/helpers/debug-log-helper";
|
import { checkLogParam } from "@/helpers/debug-log-helper";
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
|
@ -19,8 +28,21 @@ import router from "@/router";
|
||||||
|
|
||||||
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
|
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
|
||||||
|
|
||||||
|
function isRecoveryRoute(routeName) {
|
||||||
|
return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name;
|
||||||
|
}
|
||||||
|
|
||||||
export async function beforeEach(to, from) {
|
export async function beforeEach(to, from) {
|
||||||
try {
|
try {
|
||||||
|
if (isRecoveryRoute(to.name)) {
|
||||||
|
debugLog(`--- before-each.js recovery route ${to.name} ---`);
|
||||||
|
// Do NOT clear the recovery counter here. The counter must accumulate across
|
||||||
|
// consecutive failures that bounce through the ERROR/RESTART route so the
|
||||||
|
// circuit breaker can trip and fall back to the static error page. It is only
|
||||||
|
// cleared after a fully successful guard pass (end of try block).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await analyticsMixin.methods.validateSession();
|
await analyticsMixin.methods.validateSession();
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
@ -63,7 +85,7 @@ export async function beforeEach(to, from) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure session has not expired
|
// Ensure session has not expired - not primary check for session expiration and uses FunnelSessionInfo-{environment} cookie.
|
||||||
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
|
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
|
||||||
const errorPayload = {
|
const errorPayload = {
|
||||||
cause: "expired session",
|
cause: "expired session",
|
||||||
|
|
@ -141,18 +163,31 @@ export async function beforeEach(to, from) {
|
||||||
{
|
{
|
||||||
debugLog("--- before-each.js end ---");
|
debugLog("--- before-each.js end ---");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearBeforeEachErrorRecoveryCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorPayload = {
|
const errorPayload = {
|
||||||
cause: "Uncaught exception in `beforeEach`.",
|
cause: "Uncaught exception in `beforeEach`.",
|
||||||
currentPage: from?.name,
|
currentPage: from?.name,
|
||||||
nextPage: to?.name,
|
nextPage: to?.name,
|
||||||
|
errorMessage: error?.message,
|
||||||
|
errorStack: error?.stack,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
global.$logger?.logError("beforeEach uncaught exception", errorPayload);
|
||||||
|
|
||||||
|
if (to.name === routeData.ERROR.name || hasBeforeEachErrorRecoveryBeenAttempted()) {
|
||||||
|
clearBeforeEachErrorRecoveryCount();
|
||||||
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
// Eject user from Vue app in this scenario and clear localstorage.
|
// Eject user from Vue app in this scenario and clear localstorage.
|
||||||
await handleHardError(errorPayload);
|
await handleHardError(errorPayload);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
257
src/router/methods/before-each.spec.js
Normal file
257
src/router/methods/before-each.spec.js
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import analyticsMixin from "@/mixins/analytics-mixin";
|
||||||
|
import { runExperiments } from "@/router/methods/helpers/run-experiments";
|
||||||
|
import { handleHardError, redirectToStaticErrorPage } from "@/router/methods/error";
|
||||||
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
|
import { routeData } from "@/router/constants/routes";
|
||||||
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
|
||||||
|
jest.mock("@/mixins/analytics-mixin", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
methods: {
|
||||||
|
validateSession: jest.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/router/methods/helpers/run-experiments", () => ({
|
||||||
|
runExperiments: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
|
||||||
|
getFunnelCookie: jest.fn(() => null),
|
||||||
|
updateOrCreateFunnelCookie: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/helpers/heritage-integration/session-helper", () => ({
|
||||||
|
isSavedSessionStillActive: jest.fn(() => true),
|
||||||
|
getDateForSavedSessionTimeout: jest.fn(() => "2099-01-01T00:00:00.000Z"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
|
||||||
|
navigateToHeritageFunnel: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/router/methods/helpers/handle-heritage-return", () => ({
|
||||||
|
handleHeritageReturn: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/router/methods/page-prerequisites", () => ({
|
||||||
|
checkPagePrerequisites: jest.fn(() => Promise.resolve(true)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/helpers/debug-log-helper", () => ({
|
||||||
|
debugLog: jest.fn(),
|
||||||
|
checkLogParam: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/router/methods/error", () => ({
|
||||||
|
handleSoftError: jest.fn(() => Promise.resolve()),
|
||||||
|
handleHardError: jest.fn(() => Promise.resolve()),
|
||||||
|
redirectToStaticErrorPage: jest.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/router", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
push: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { beforeEach as routerBeforeEach } from "./before-each";
|
||||||
|
import {
|
||||||
|
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||||
|
incrementBeforeEachErrorRecoveryCount,
|
||||||
|
resetBeforeEachErrorRecoveryStateForTests,
|
||||||
|
} from "./before-each-error-recovery";
|
||||||
|
|
||||||
|
describe("router/methods/before-each", () => {
|
||||||
|
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
resetBeforeEachErrorRecoveryStateForTests();
|
||||||
|
window.sessionStorage.removeItem(recoveryKey);
|
||||||
|
window.sessionStorage.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
|
||||||
|
analyticsMixin.methods.validateSession.mockResolvedValue(undefined);
|
||||||
|
debugLog.mockImplementation(() => undefined);
|
||||||
|
global.$logger = {
|
||||||
|
logError: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips validateSession and runExperiments when navigating to ERROR", async () => {
|
||||||
|
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
|
||||||
|
|
||||||
|
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
|
||||||
|
expect(runExperiments).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips validateSession and runExperiments when navigating to RESTART", async () => {
|
||||||
|
await routerBeforeEach({ name: routeData.RESTART.name }, { name: routeData.VEHICLE.name });
|
||||||
|
|
||||||
|
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
|
||||||
|
expect(runExperiments).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears recovery counter after successful funnel navigation", async () => {
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
|
||||||
|
await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves recovery counter when passing through the ERROR recovery route", async () => {
|
||||||
|
incrementBeforeEachErrorRecoveryCount();
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
|
||||||
|
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
|
||||||
|
|
||||||
|
// The counter must survive the ERROR bounce so consecutive failures can trip the
|
||||||
|
// circuit breaker; it is only cleared after a fully successful guard pass.
|
||||||
|
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs validateSession on a normal funnel page", async () => {
|
||||||
|
await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(analyticsMixin.methods.validateSession).toHaveBeenCalled();
|
||||||
|
expect(runExperiments).toHaveBeenCalledWith(routeData.VEHICLE_DAMAGE.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls handleHardError once on uncaught exception", async () => {
|
||||||
|
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||||
|
|
||||||
|
const result = await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handleHardError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleHardError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cause: "Uncaught exception in `beforeEach`.",
|
||||||
|
errorMessage: "session failure",
|
||||||
|
errorStack: expect.any(String),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||||
|
"beforeEach uncaught exception",
|
||||||
|
expect.objectContaining({ errorMessage: "session failure" })
|
||||||
|
);
|
||||||
|
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to static error page when exception occurs on ERROR route", async () => {
|
||||||
|
debugLog.mockImplementation(() => {
|
||||||
|
throw new Error("recovery route failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await routerBeforeEach(
|
||||||
|
{ name: routeData.ERROR.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||||
|
"beforeEach uncaught exception",
|
||||||
|
expect.objectContaining({
|
||||||
|
errorMessage: "recovery route failure",
|
||||||
|
nextPage: routeData.ERROR.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(redirectToStaticErrorPage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cause: "Uncaught exception in `beforeEach`.",
|
||||||
|
errorMessage: "recovery route failure",
|
||||||
|
nextPage: routeData.ERROR.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(handleHardError).not.toHaveBeenCalled();
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to static error page on second uncaught exception in session", async () => {
|
||||||
|
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||||
|
window.sessionStorage.setItem(recoveryKey, "1");
|
||||||
|
|
||||||
|
const result = await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||||
|
"beforeEach uncaught exception",
|
||||||
|
expect.objectContaining({ cause: "Uncaught exception in `beforeEach`." })
|
||||||
|
);
|
||||||
|
expect(redirectToStaticErrorPage).toHaveBeenCalled();
|
||||||
|
expect(handleHardError).not.toHaveBeenCalled();
|
||||||
|
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to static error page on second failure when sessionStorage throws", async () => {
|
||||||
|
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||||
|
|
||||||
|
const storageBlockedError = new Error("sessionStorage blocked");
|
||||||
|
const underlyingStorage = window.sessionStorage;
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem(key) {
|
||||||
|
if (key === recoveryKey) {
|
||||||
|
throw storageBlockedError;
|
||||||
|
}
|
||||||
|
return underlyingStorage.getItem(key);
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
if (key === recoveryKey) {
|
||||||
|
throw storageBlockedError;
|
||||||
|
}
|
||||||
|
underlyingStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key) {
|
||||||
|
if (key === recoveryKey) {
|
||||||
|
throw storageBlockedError;
|
||||||
|
}
|
||||||
|
underlyingStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
expect(handleHardError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
const result = await routerBeforeEach(
|
||||||
|
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||||
|
{ name: routeData.VEHICLE.name }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(redirectToStaticErrorPage).toHaveBeenCalled();
|
||||||
|
expect(handleHardError).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
Object.defineProperty(window, "sessionStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: underlyingStorage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,9 +3,41 @@ import { routeData } from "@/router/constants/routes";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import { storeActions } from "@/constants/store-actions";
|
import { storeActions } from "@/constants/store-actions";
|
||||||
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
||||||
|
import { clearBeforeEachErrorRecoveryCount } from "@/router/methods/before-each-error-recovery";
|
||||||
import experimentMixin from "../../mixins/experiment-mixin";
|
import experimentMixin from "../../mixins/experiment-mixin";
|
||||||
|
|
||||||
|
const STATIC_ERROR_PAGE_PATH = "/fmg/static/error";
|
||||||
|
|
||||||
|
/** Clears sessionStorage keys that can re-poison the funnel after a bailout. */
|
||||||
|
export function clearBailoutSessionStorage() {
|
||||||
|
clearBeforeEachErrorRecoveryCount();
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage?.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
|
||||||
|
window.sessionStorage?.removeItem(
|
||||||
|
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
|
||||||
|
);
|
||||||
|
window.sessionStorage?.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redirectToStaticErrorPage(errorPayload) {
|
||||||
|
await logErrorToDataLayerAndLogger("redirectToStaticErrorPage", errorPayload, null);
|
||||||
|
clearBailoutSessionStorage();
|
||||||
|
|
||||||
|
// Prefer top-level navigation so the static page loads outside any iframe wrapper.
|
||||||
|
// Assigning window.top.location throws SecurityError when cross-origin; fall back to this frame.
|
||||||
|
try {
|
||||||
|
window.top.location = STATIC_ERROR_PAGE_PATH;
|
||||||
|
} catch {
|
||||||
|
window.location = STATIC_ERROR_PAGE_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSoftError(errorPayload, forceRestart = false) {
|
export async function handleSoftError(errorPayload, forceRestart = false) {
|
||||||
if (forceRestart) {
|
if (forceRestart) {
|
||||||
await store.dispatch(storeActions.RESET_STATE);
|
await store.dispatch(storeActions.RESET_STATE);
|
||||||
|
|
@ -27,8 +59,7 @@ export async function handleHardError(errorPayload) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isInStaticErrorExperiment) {
|
if (isInStaticErrorExperiment) {
|
||||||
logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
window.top.location = "/fmg/static/error";
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
await handleSoftError(errorPayload, true);
|
await handleSoftError(errorPayload, true);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { handleSoftError, handleHardError } from "./error";
|
import { handleSoftError, handleHardError, redirectToStaticErrorPage } from "./error";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import analyticsMixin from "@/mixins/analytics-mixin";
|
import analyticsMixin from "@/mixins/analytics-mixin";
|
||||||
import experimentMixin from "../../mixins/experiment-mixin";
|
import experimentMixin from "../../mixins/experiment-mixin";
|
||||||
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
||||||
import { storeActions } from "@/constants/store-actions";
|
import { storeActions } from "@/constants/store-actions";
|
||||||
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
import { routeData } from "@/router/constants/routes";
|
import { routeData } from "@/router/constants/routes";
|
||||||
|
|
||||||
jest.mock("@/router", () => ({
|
jest.mock("@/router", () => ({
|
||||||
|
|
@ -98,6 +99,126 @@ describe("router/methods/error", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redirectToStaticErrorPage", () => {
|
||||||
|
let topLocationValue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
topLocationValue = undefined;
|
||||||
|
Object.defineProperty(window, "top", {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.top, "location", {
|
||||||
|
configurable: true,
|
||||||
|
set(value) {
|
||||||
|
topLocationValue = value;
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return topLocationValue;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and sets static error location", async () => {
|
||||||
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
|
|
||||||
|
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
|
||||||
|
errorPayload
|
||||||
|
);
|
||||||
|
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||||
|
"redirectToStaticErrorPage: test cause",
|
||||||
|
errorPayload
|
||||||
|
);
|
||||||
|
expect(topLocationValue).toBe("/fmg/static/error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears bailout sessionStorage keys before navigating away", async () => {
|
||||||
|
window.sessionStorage.setItem(sessionStorageKeyConstants.SUBMITTED_STATE, "not-json");
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT,
|
||||||
|
"3"
|
||||||
|
);
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT,
|
||||||
|
"1"
|
||||||
|
);
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
|
||||||
|
"{}"
|
||||||
|
);
|
||||||
|
|
||||||
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
window.sessionStorage.getItem(
|
||||||
|
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
window.sessionStorage.getItem(
|
||||||
|
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to window.location when window.top.location setter throws", async () => {
|
||||||
|
let windowLocationValue;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
set(value) {
|
||||||
|
windowLocationValue = value;
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return windowLocationValue;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.top, "location", {
|
||||||
|
configurable: true,
|
||||||
|
set() {
|
||||||
|
throw new Error("cross-origin frame");
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
|
|
||||||
|
expect(windowLocationValue).toBe("/fmg/static/error");
|
||||||
|
expect(topLocationValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits logging before navigating away", async () => {
|
||||||
|
const callOrder = [];
|
||||||
|
analyticsMixin.methods.pushPageErrorToDataLayer.mockImplementation(() => {
|
||||||
|
callOrder.push("datalayer");
|
||||||
|
});
|
||||||
|
global.$logger.logError.mockImplementation(() => {
|
||||||
|
callOrder.push("logger");
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.top, "location", {
|
||||||
|
configurable: true,
|
||||||
|
set(value) {
|
||||||
|
callOrder.push("navigate");
|
||||||
|
topLocationValue = value;
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return topLocationValue;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await redirectToStaticErrorPage(errorPayload);
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["datalayer", "logger", "navigate"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleHardError", () => {
|
describe("handleHardError", () => {
|
||||||
let topLocationValue;
|
let topLocationValue;
|
||||||
|
|
||||||
|
|
@ -131,7 +252,7 @@ describe("router/methods/error", () => {
|
||||||
errorPayload
|
errorPayload
|
||||||
);
|
);
|
||||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||||
"handleHardError: test cause",
|
"redirectToStaticErrorPage: test cause",
|
||||||
errorPayload
|
errorPayload
|
||||||
);
|
);
|
||||||
expect(topLocationValue).toBe("/fmg/static/error");
|
expect(topLocationValue).toBe("/fmg/static/error");
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export async function errorBeforeEnter(to, from) {
|
||||||
|
|
||||||
// If we've already encountered one error, clear session to avoid more.
|
// If we've already encountered one error, clear session to avoid more.
|
||||||
// Otherwise mark that we encountered an error here.
|
// Otherwise mark that we encountered an error here.
|
||||||
|
// Note: an earlier breaker also runs in before-each.js via before-each-error-recovery.js
|
||||||
|
// (consecutive uncaught guard failures before this route handler runs).
|
||||||
if (store.getters.applicationUser.hasAlreadyTriggeredError) {
|
if (store.getters.applicationUser.hasAlreadyTriggeredError) {
|
||||||
const errorPayload = {
|
const errorPayload = {
|
||||||
cause: "Successive errors triggered.",
|
cause: "Successive errors triggered.",
|
||||||
|
|
|
||||||
|
|
@ -3737,7 +3737,7 @@ export const actions = {
|
||||||
context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments);
|
context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments);
|
||||||
|
|
||||||
//clear externalParameter session storage
|
//clear externalParameter session storage
|
||||||
window.sessionStorage.removeItem("externalParameterState");
|
window.sessionStorage.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
|
||||||
|
|
||||||
//restore affiliate cookies
|
//restore affiliate cookies
|
||||||
context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies);
|
context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies);
|
||||||
|
|
@ -4451,14 +4451,21 @@ function createExternalParameterDefaultState() {
|
||||||
saveExternalParameterState(externalParameterDefaultState);
|
saveExternalParameterState(externalParameterDefaultState);
|
||||||
}
|
}
|
||||||
function getExternalParameterDefaultState() {
|
function getExternalParameterDefaultState() {
|
||||||
const externalParameterState = window.sessionStorage.getItem("externalParameterState");
|
const externalParameterState = window.sessionStorage.getItem(
|
||||||
|
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE
|
||||||
|
);
|
||||||
if (externalParameterState === null) {
|
if (externalParameterState === null) {
|
||||||
createExternalParameterDefaultState();
|
createExternalParameterDefaultState();
|
||||||
}
|
}
|
||||||
return JSON.parse(window.sessionStorage.getItem("externalParameterState"));
|
return JSON.parse(
|
||||||
|
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function saveExternalParameterState(externalParameterState) {
|
function saveExternalParameterState(externalParameterState) {
|
||||||
window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState));
|
window.sessionStorage.setItem(
|
||||||
|
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
|
||||||
|
JSON.stringify(externalParameterState)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeSlotCallFlags = {
|
const timeSlotCallFlags = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue