diff --git a/frontend/package.json b/frontend/package.json index 444e132e50..689343a90b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "start": "vite --host --port 3000", "build": "vue-tsc --noEmit && vite build", "preview": "cross-env VITE_NODE_ENV=test start-server-and-test stub http://127.0.0.1:8080/ 'vite --host --port 3000'", + "preview:app": "cross-env VITE_NODE_ENV=test vite --host --port 3000", "typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "lint": "eslint . --fix --ignore-path .gitignore", "stub": "wiremock --enable-stub-cors --port 8080 --https-port 8081 --preserve-host-header --root-dir ./stub --verbose --global-response-templating", diff --git a/frontend/src/components/grouping/AddressGroupComponent.vue b/frontend/src/components/grouping/AddressGroupComponent.vue index 1476a84fec..efa6c343dd 100644 --- a/frontend/src/components/grouping/AddressGroupComponent.vue +++ b/frontend/src/components/grouping/AddressGroupComponent.vue @@ -30,8 +30,6 @@ const emit = defineEmits<{ (e: "remove", value: number): void; }>(); -const generalErrorBus = useEventBus("general-error-notification"); - const noValidation = (value: string) => ""; //We set it as a separated ref due to props not being updatable @@ -211,8 +209,6 @@ watch([autoCompleteResult], () => { ); watch([error], () => { - // @ts-ignore - generalErrorBus.emit(error.response?.data.message); postalCodeShowHint.value = true; }); diff --git a/frontend/src/composables/useFetch.ts b/frontend/src/composables/useFetch.ts index dcd05fa762..66773f8fa7 100644 --- a/frontend/src/composables/useFetch.ts +++ b/frontend/src/composables/useFetch.ts @@ -19,7 +19,25 @@ export const useFetch = (url: string | Ref, config: any = {}) => { const data: any = ref(config.initialData || {}); const info = useFetchTo(url, data, config); - return { ...info, data }; + return info; +}; + +const handleErrorDefault = (error: any) => { + if ( + error.code === "ERR_BAD_RESPONSE" || + error.code === "ERR_NETWORK" + ) { + notificationBus.emit({ + fieldId: "internal.server.error", + errorMsg: "", + }); + } + else if (error.code === "ERR_BAD_REQUEST") { + notificationBus.emit({ + fieldId: "bad.request.error", + errorMsg: "", + }); + } }; /** @@ -61,21 +79,10 @@ export const useFetchTo = ( data.value = result.data; } catch (ex) { error.value = ex; - if ( - error.value.code === "ERR_BAD_RESPONSE" || - error.value.code === "ERR_NETWORK" - ) { - notificationBus.emit({ - fieldId: "internal.server.error", - errorMsg: "", - }); - } - else if (error.value.code === "ERR_BAD_REQUEST") { - notificationBus.emit({ - fieldId: "bad.request.error", - errorMsg: "", - }); + if (config.skipDefaultErrorHandling) { + return; } + apiDataHandler.handleErrorDefault(); } finally { loading.value = false; } @@ -83,7 +90,16 @@ export const useFetchTo = ( !config.skip && fetch(); - return { response, error, data, loading, fetch }; + const apiDataHandler = { + response, + error, + data, + loading, + fetch, + handleErrorDefault: () => handleErrorDefault(error.value), + }; + + return apiDataHandler; }; /** @@ -123,26 +139,24 @@ export const usePost = (url: string, body: any, config: any = {}) => { responseBody.value = result.data; } catch (ex: any) { error.value = ex; - if ( - error.value.code === "ERR_BAD_RESPONSE" || - error.value.code === "ERR_NETWORK" - ) { - notificationBus.emit({ - fieldId: "internal.server.error", - errorMsg: "", - }); - } - else if (error.value.code === "ERR_BAD_REQUEST") { - notificationBus.emit({ - fieldId: "bad.request.error", - errorMsg: "", - }); + if (config.skipDefaultErrorHandling) { + return; } + apiDataHandler.handleErrorDefault(); } finally { loading.value = false; } }; !config.skip && fetch(); - return { response, error, responseBody, loading, fetch }; + const apiDataHandler = { + response, + error, + responseBody, + loading, + fetch, + handleErrorDefault: () => handleErrorDefault(error.value), + }; + + return apiDataHandler; }; diff --git a/frontend/src/pages/FormBCSCPage.vue b/frontend/src/pages/FormBCSCPage.vue index 1d5fed5d9e..e237a402e7 100644 --- a/frontend/src/pages/FormBCSCPage.vue +++ b/frontend/src/pages/FormBCSCPage.vue @@ -335,6 +335,7 @@ const { response, error, fetch: fetchSubmit, + handleErrorDefault, } = usePost("/api/clients/submissions", toRef(formData).value, { skip: true, }); @@ -364,20 +365,29 @@ watch([error], () => { // reset the button to allow a new submission attempt submitBtnDisabled.value = !validInd.value; - const validationErrors: ValidationMessageType[] = - error.value.response?.data ?? ([] as ValidationMessageType[]); + if (Array.isArray(error.value.response?.data)) { + const validationErrors: ValidationMessageType[] = error.value.response?.data; + + validationErrors.forEach((errorItem: ValidationMessageType) => + notificationBus.emit({ + fieldId: "server.validation.error", + fieldName: convertFieldNameToSentence(errorItem.fieldId), + errorMsg: errorItem.errorMsg, + }), + ); + } else { + handleErrorDefault(); + } - validationErrors.forEach((errorItem: ValidationMessageType) => - notificationBus.emit({ - fieldId: "server.validation.error", - fieldName: convertFieldNameToSentence(errorItem.fieldId), - errorMsg: errorItem.errorMsg, - }) - ); setScrollPoint("top-notification"); }); -const { error:validationError } = useFetch(`/api/clients/individual/${ForestClientUserSession.user?.userId.split('\\').pop()}?lastName=${ForestClientUserSession.user?.lastName}`); +const { error: validationError, handleErrorDefault: handleValidationError } = useFetch( + `/api/clients/individual/${ForestClientUserSession.user?.userId + .split("\\") + .pop()}?lastName=${ForestClientUserSession.user?.lastName}`, + { skipDefaultErrorHandling: true }, +); watch([validationError], () => { if (validationError.value.response?.status === 409) { updateValidState(-1, false); //-1 to define the error as global @@ -385,8 +395,11 @@ watch([validationError], () => { fieldId: "server.validation.error", fieldName: '', errorMsg: validationError.value.response?.data ?? "", - }) - } + }); + } else if (validationError.value.response?.status !== 404) { + updateValidState(-1, false); //-1 to define the error as global + handleValidationError(); + } }); const districtsList = ref([]); diff --git a/frontend/src/pages/FormBCeIDPage.vue b/frontend/src/pages/FormBCeIDPage.vue index daf892cbbd..cfa165403a 100644 --- a/frontend/src/pages/FormBCeIDPage.vue +++ b/frontend/src/pages/FormBCeIDPage.vue @@ -107,13 +107,15 @@ const associatedLocations = computed(() => ) ); -const { response, error, fetch: post } = usePost( - "/api/clients/submissions", - toRef(formData).value, - { - skip: true, - } -); +const { + response, + error, + fetch: post, + handleErrorDefault, +} = usePost("/api/clients/submissions", toRef(formData).value, { + skip: true, + skipDefaultErrorHandling: true, +}); watch([response], () => { if (response.value.status === 201) { @@ -125,16 +127,20 @@ watch([error], () => { // reset the button to allow a new submission attempt submitBtnDisabled.value = false; - const validationErrors: ValidationMessageType[] = error.value.response?.data ?? - [] as ValidationMessageType[]; + if (Array.isArray(error.value.response?.data)) { + const validationErrors: ValidationMessageType[] = error.value.response?.data; + + validationErrors.forEach((errorItem: ValidationMessageType) => + notificationBus.emit({ + fieldId: "server.validation.error", + fieldName: convertFieldNameToSentence(errorItem.fieldId), + errorMsg: errorItem.errorMsg, + }), + ); + } else { + handleErrorDefault(); + } - validationErrors.forEach((errorItem: ValidationMessageType) => - notificationBus.emit({ - fieldId: "server.validation.error", - fieldName: convertFieldNameToSentence(errorItem.fieldId), - errorMsg: errorItem.errorMsg, - }) - ); setScrollPoint("top-notification"); }); diff --git a/frontend/src/pages/bceidform/BusinessInformationWizardStep.vue b/frontend/src/pages/bceidform/BusinessInformationWizardStep.vue index b8a86f270c..c5cb2e4971 100644 --- a/frontend/src/pages/bceidform/BusinessInformationWizardStep.vue +++ b/frontend/src/pages/bceidform/BusinessInformationWizardStep.vue @@ -54,9 +54,7 @@ const ForestClientUserSession = instance.appContext.config.globalProperties.$ses const progressIndicatorBus = useEventBus( "progress-indicator-bus" ); -const exitBus = - useEventBus>("exit-notification"); -const generalErrorBus = useEventBus("general-error-notification"); +const exitBus = useEventBus>("exit-notification"); //Set the prop as a ref, and then emit when it changes const formData = ref(props.data); @@ -176,11 +174,13 @@ watch([autoCompleteResult], () => { emit("update:data", formData.value); //Also, we will load the backend data to fill all the other information as well - const { error, loading: detailsLoading } = useFetchTo( - `/api/clients/${autoCompleteResult.value.code}`, - detailsData, - {} - ); + const { + error, + loading: detailsLoading, + handleErrorDefault, + } = useFetchTo(`/api/clients/${autoCompleteResult.value.code}`, detailsData, { + skipDefaultErrorHandling: true, + }); showDetailsLoading.value = true; watch(error, () => { @@ -207,8 +207,7 @@ watch([autoCompleteResult], () => { emit("update:data", formData.value); return; } - // @ts-ignore - generalErrorBus.emit(error.value.response?.data.message); + handleErrorDefault(); }); watch( @@ -227,10 +226,15 @@ watch([autoCompleteResult], () => { * @param {string} lastName - The last name to check. */ const checkForIndividualValid = (lastName: string) => { - const { error: validationError, response: individualResponse } = useFetch( + const { + error: validationError, + response: individualResponse, + handleErrorDefault, + } = useFetch( `/api/clients/individual/${ForestClientUserSession.user?.userId .split("\\") - .pop()}?lastName=${lastName}` + .pop()}?lastName=${lastName}`, + { skipDefaultErrorHandling: true }, ); // reset validation @@ -240,13 +244,14 @@ const checkForIndividualValid = (lastName: string) => { if (watchValue.response?.status === 409) { validation.business = false; toggleErrorMessages(null, true, null); - generalErrorBus.emit(watchValue.response?.data ?? ""); } else if (watchValue.response?.status === 404) { validation.individual = true; + } else { + handleErrorDefault(); } }); watch(individualResponse, (watchValue) => { - if(watchValue.status === 200){ + if (watchValue.status === 200) { validation.individual = true; } }); diff --git a/frontend/tests/unittests/composables/useFetch.spec.ts b/frontend/tests/unittests/composables/useFetch.spec.ts index f3e2f9d05c..e1d20f9abd 100644 --- a/frontend/tests/unittests/composables/useFetch.spec.ts +++ b/frontend/tests/unittests/composables/useFetch.spec.ts @@ -1,15 +1,24 @@ -import { describe, it, expect, afterEach, vi } from 'vitest' +import { describe, it, expect, afterEach, vi, MockInstance } from 'vitest' import { mount } from '@vue/test-utils' import { ref, watch } from 'vue' import axios from 'axios' -import { useFetch, usePost } from '@/composables/useFetch' +import { useFetch, usePost, useFetchTo } from '@/composables/useFetch' describe('useFetch', () => { let axiosMock afterEach(() => { - axiosMock.mockRestore() - }) + if (axiosMock) { + axiosMock.mockRestore(); + } + }); + + // This test assures we can use this value as initial data (before a fetch is performed). + it("should preserve the reference to the supplied data parameter", () => { + const dataRef = ref({ key: "value" }); + const result = useFetchTo("/", dataRef, { skip: true }); + expect(result.data).toBe(dataRef); + }); it('should make a GET request using Axios', async () => { axiosMock = vi @@ -88,91 +97,222 @@ describe('useFetch', () => { }) }) - it('should make a GET request using Axios and get an error', async () => { + it("should make a GET request using Axios and get an error", async () => { axiosMock = vi - .spyOn(axios, 'request') + .spyOn(axios, "request") .mockImplementation(() => - Promise.reject( - new Error({ response: { status: 500, data: { message: 'Error' } } }) - ) - ) - const responseData = ref(null) + Promise.reject(new Error({ response: { status: 500, data: { message: "Error" } } })), + ); + const responseData = ref(null); + + let fetchWrapper: ReturnType; + + const doSpyHandleErrorDefault = () => vi.spyOn(fetchWrapper, "handleErrorDefault"); + let spyHandleErrorDefault: ReturnType; const TestComponent = { - template: '
', + template: "
", setup: () => { - const { fetch, error } = useFetch('/api/data', { skip: true }) - watch(error, (value) => (responseData.value = value)) - fetch() - } - } + fetchWrapper = useFetch("/api/data", { skip: true }); + // spyHandleErrorDefault = doSpyHandleErrorDefault(); + spyHandleErrorDefault = doSpyHandleErrorDefault(); + const { fetch, error } = fetchWrapper; + watch(error, (value) => (responseData.value = value)); + fetch(); + }, + }; watch(responseData, (value) => { expect(value).toStrictEqual( - new Error({ response: { status: 500, data: { message: 'Error' } } }) - ) - }) + new Error({ response: { status: 500, data: { message: "Error" } } }), + ); + }); - const wrapper = mount(TestComponent) + const wrapper = mount(TestComponent); - await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick(); expect(axiosMock).toHaveBeenCalledWith({ - baseURL: 'http://localhost:8080', + baseURL: "http://localhost:8080", headers: { - 'Access-Control-Allow-Origin': 'http://localhost:3000', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer undefined', + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json", + Authorization: "Bearer undefined", }, skip: true, - url: '/api/data' - }) - }) + url: "/api/data", + }); + + // Await the axios mock to resolve + await wrapper.vm.$nextTick(); - it('should make a POST request using Axios and get an error', async () => { - axiosMock = vi.spyOn(axios, 'request').mockImplementation(() => + expect(spyHandleErrorDefault).toHaveBeenCalled(); + }); + + it("should get an error from a GET request but not perform the default error handling", async () => { + axiosMock = vi + .spyOn(axios, "request") + .mockImplementation(() => + Promise.reject(new Error({ response: { status: 500, data: { message: "Error" } } })), + ); + const responseData = ref(null); + + let fetchWrapper: ReturnType; + + const doSpyHandleErrorDefault = () => vi.spyOn(fetchWrapper, "handleErrorDefault"); + let spyHandleErrorDefault: ReturnType; + + const TestComponent = { + template: "
", + setup: () => { + fetchWrapper = useFetch("/api/data", { skip: true, skipDefaultErrorHandling: true }); + spyHandleErrorDefault = doSpyHandleErrorDefault(); + const { fetch, error } = fetchWrapper; + watch(error, (value) => (responseData.value = value)); + fetch(); + }, + }; + + watch(responseData, (value) => { + expect(value).toStrictEqual( + new Error({ response: { status: 500, data: { message: "Error" } } }), + ); + }); + + const wrapper = mount(TestComponent); + + await wrapper.vm.$nextTick(); + + expect(axiosMock).toHaveBeenCalledWith({ + baseURL: "http://localhost:8080", + headers: { + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json", + Authorization: "Bearer undefined", + }, + skip: true, + skipDefaultErrorHandling: true, + url: "/api/data", + }); + + // Await the axios mock to resolve + await wrapper.vm.$nextTick(); + + expect(spyHandleErrorDefault).not.toHaveBeenCalled(); + }); + + it("should make a POST request using Axios and get an error", async () => { + axiosMock = vi.spyOn(axios, "request").mockImplementation(() => Promise.reject( new Error({ - response: { status: 500, data: { message: 'Error' } } - }) - ) - ) - const responseData = ref(null) + response: { status: 500, data: { message: "Error" } }, + }), + ), + ); + const responseData = ref(null); + + let fetchWrapper: ReturnType; + + const doSpyHandleErrorDefault = () => vi.spyOn(fetchWrapper, "handleErrorDefault"); + let spyHandleErrorDefault: ReturnType; const TestComponent = { - template: '
', + template: "
", setup: () => { - const { fetch, error } = usePost( - '/api/data', - { name: 'test' }, - { skip: true } - ) - watch(error, (value) => (responseData.value = value)) - fetch() - } - } + fetchWrapper = usePost("/api/data", { name: "test" }, { skip: true }); + spyHandleErrorDefault = doSpyHandleErrorDefault(); + const { fetch, error } = fetchWrapper; + watch(error, (value) => (responseData.value = value)); + fetch(); + }, + }; watch(responseData, (value) => { expect(value).toStrictEqual( - new Error({ response: { status: 500, data: { message: 'Error' } } }) - ) - }) + new Error({ response: { status: 500, data: { message: "Error" } } }), + ); + }); - const wrapper = mount(TestComponent) + const wrapper = mount(TestComponent); - await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick(); expect(axiosMock).toHaveBeenCalledWith({ - baseURL: 'http://localhost:8080', + baseURL: "http://localhost:8080", headers: { - 'Access-Control-Allow-Origin': 'http://localhost:3000', - 'Content-Type': 'application/json', - 'Authorization': 'Bearer undefined', + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json", + Authorization: "Bearer undefined", }, skip: true, - url: '/api/data', - method: 'POST', - data: { name: 'test' } - }) - }) -}) + url: "/api/data", + method: "POST", + data: { name: "test" }, + }); + + // Await the axios mock to resolve + await wrapper.vm.$nextTick(); + + expect(spyHandleErrorDefault).toHaveBeenCalled(); + }); + + it("should get an error from a POST request but not perform the default error handling", async () => { + axiosMock = vi.spyOn(axios, "request").mockImplementation(() => + Promise.reject( + new Error({ + response: { status: 500, data: { message: "Error" } }, + }), + ), + ); + const responseData = ref(null); + + let fetchWrapper: ReturnType; + + const doSpyHandleErrorDefault = () => vi.spyOn(fetchWrapper, "handleErrorDefault"); + let spyHandleErrorDefault: ReturnType; + + const TestComponent = { + template: "
", + setup: () => { + fetchWrapper = usePost( + "/api/data", + { name: "test" }, + { skip: true, skipDefaultErrorHandling: true }, + ); + spyHandleErrorDefault = doSpyHandleErrorDefault(); + const { fetch, error } = fetchWrapper; + watch(error, (value) => (responseData.value = value)); + fetch(); + }, + }; + + watch(responseData, (value) => { + expect(value).toStrictEqual( + new Error({ response: { status: 500, data: { message: "Error" } } }), + ); + }); + + const wrapper = mount(TestComponent); + + await wrapper.vm.$nextTick(); + + expect(axiosMock).toHaveBeenCalledWith({ + baseURL: "http://localhost:8080", + headers: { + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json", + Authorization: "Bearer undefined", + }, + skip: true, + skipDefaultErrorHandling: true, + url: "/api/data", + method: "POST", + data: { name: "test" }, + }); + + // Await the axios mock to resolve + await wrapper.vm.$nextTick(); + + expect(spyHandleErrorDefault).not.toHaveBeenCalled(); + }); +});