diff --git a/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_is_bsol_workflow.cy.js b/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_is_bsol_workflow.cy.js index d5ab01524..9d79fc49e 100644 --- a/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_is_bsol_workflow.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_is_bsol_workflow.cy.js @@ -127,8 +127,7 @@ describe('GP Workflow: View Lloyd George record', () => { cy.get('#verify-submit').click(); //Assert - assertPatientInfo(); - assertFailedLloydGeorgeLoad(); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }, ); }); @@ -136,7 +135,7 @@ describe('GP Workflow: View Lloyd George record', () => { context('View Lloyd George document with specific role tests', () => { it( - 'It displays an error with a download link when a Lloyd George stitching timeout occures via the API Gatway for a GP_ADMIN', + 'It displays an error with a download link when a Lloyd George stitching timeout occurs via the API Gateway for a GP_ADMIN', { tags: 'regression' }, () => { beforeEachConfiguration(Roles.GP_ADMIN); @@ -151,7 +150,7 @@ describe('GP Workflow: View Lloyd George record', () => { ); it( - 'It displays an error with download link when a Lloyd George stitching timeout occures via the API Gatway for a GP_CLINICAL but link access is denied', + 'It displays an error with download link when a Lloyd George stitching timeout occurs via the API Gateway for a GP_CLINICAL but link access is denied', { tags: 'regression' }, () => { beforeEachConfiguration(Roles.GP_CLINICAL); @@ -287,7 +286,7 @@ describe('GP Workflow: View Lloyd George record', () => { cy.wait('@documentDelete'); // assert - cy.getByTestId('service-error').should('be.visible'); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }, ); @@ -346,7 +345,7 @@ describe('GP Workflow: View Lloyd George record', () => { cy.wait('@documentManifest'); // Assert - cy.contains('An error has occurred while preparing your download').should('be.visible'); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }); }); }); diff --git a/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_not_bsol_workflow.cy.js b/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_not_bsol_workflow.cy.js index 237e0ec31..01f0ee783 100644 --- a/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_not_bsol_workflow.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/gp_user_workflows/view_lloyd_george_not_bsol_workflow.cy.js @@ -136,8 +136,9 @@ describe('GP Workflow: View Lloyd George record', () => { cy.get('#verify-submit').click(); //Assert - assertPatientInfo(); - assertFailedLloydGeorgeLoad(); + cy.contains('Sorry, there is a problem with the service').should( + 'be.visible', + ); }, ); diff --git a/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js b/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js index 7766e4b2e..5551b0ead 100644 --- a/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/pcse_user_workflows/download_patient_files_workflow.cy.js @@ -180,7 +180,7 @@ describe('PCSE Workflow: Access and download found files', () => { navigateToDownload(roles.PCSE); - cy.get('#service-error').should('exist'); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }, ); @@ -307,7 +307,7 @@ describe('PCSE Workflow: Access and download found files', () => { cy.getByTestId('delete-submit-btn').click(); // assert - cy.getByTestId('service-error').should('be.visible'); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }, ); @@ -330,7 +330,7 @@ describe('PCSE Workflow: Access and download found files', () => { cy.getByTestId('delete-submit-btn').click(); // assert - cy.getByTestId('service-error').should('be.visible'); + cy.contains('Sorry, there is a problem with the service').should('be.visible'); }, ); }); diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx index 625e2a7a6..2f44aa74b 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx @@ -162,7 +162,7 @@ describe('DeleteDocumentsStage', () => { const errorResponse = { response: { status: 500, - message: 'Client Error.', + data: { message: 'Client Error', err_code: 'SP_1001' }, }, }; mockedAxios.delete.mockImplementation(() => Promise.reject(errorResponse)); @@ -182,6 +182,12 @@ describe('DeleteDocumentsStage', () => { screen.getByText('Sorry, the service is currently unavailable.'), ).toBeInTheDocument(); }); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=SP_1001', + ); + }); }); }); }); diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx index 505bc4e8e..4c7560572 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx @@ -19,6 +19,7 @@ import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; import useBaseAPIUrl from '../../../helpers/hooks/useBaseAPIUrl'; import usePatient from '../../../helpers/hooks/usePatient'; +import { errorToParams } from '../../../helpers/utils/errorToParams'; export type Props = { docType: DOCUMENT_TYPE; @@ -80,7 +81,6 @@ function DeleteDocumentsStage({ if (response.status === 200) { setDeletionStage(SUBMISSION_STATE.SUCCEEDED); - if (setDownloadStage) { setDownloadStage(DOWNLOAD_STAGE.FAILED); } @@ -89,6 +89,8 @@ function DeleteDocumentsStage({ const error = e as AxiosError; if (error.response?.status === 403) { navigate(routes.START); + } else { + navigate(routes.SERVER_ERROR + errorToParams(error)); } setDeletionStage(SUBMISSION_STATE.FAILED); } diff --git a/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx b/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx index 5d25f4d6e..abe8b4e43 100644 --- a/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx +++ b/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx @@ -104,7 +104,7 @@ describe('DocumentSearchResultsOptions', () => { const errorResponse = { response: { status: 400, - message: 'Error', + data: { message: 'Error', err_code: 'SP_1001' }, }, }; mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); @@ -154,6 +154,24 @@ describe('DocumentSearchResultsOptions', () => { expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); }); }); + it('navigates to error page when API returns 5XX', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'Server error', err_code: 'SP_1001' }, + }, + }; + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + renderDocumentSearchResultsOptions(SUBMISSION_STATE.INITIAL); + userEvent.click(screen.getByRole('button', { name: 'Download All Documents' })); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=SP_1001', + ); + }); + }); }); }); diff --git a/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx b/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx index 66bcb3499..3668ec9bf 100644 --- a/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx +++ b/app/src/components/blocks/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx @@ -9,6 +9,7 @@ import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import useBaseAPIHeaders from '../../../helpers/hooks/useBaseAPIHeaders'; import { DOCUMENT_TYPE } from '../../../types/pages/UploadDocumentsPage/types'; import useBaseAPIUrl from '../../../helpers/hooks/useBaseAPIUrl'; +import { errorToParams } from '../../../helpers/utils/errorToParams'; type Props = { nhsNumber: string; @@ -58,6 +59,8 @@ const DocumentSearchResultsOptions = (props: Props) => { const error = e as AxiosError; if (error.response?.status === 403) { navigate(routes.START); + } else { + navigate(routes.SERVER_ERROR + errorToParams(error)); } props.updateDownloadState(SUBMISSION_STATE.FAILED); } diff --git a/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx b/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx index 6bce98856..44734a1ce 100644 --- a/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx +++ b/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx @@ -6,9 +6,12 @@ import FeedbackForm, { Props } from './FeedbackForm'; import { fillInForm } from '../../../helpers/test/formUtils'; jest.mock('../../../helpers/requests/sendEmail'); +const mockedUseNavigate = jest.fn(); const mockSendEmail = sendEmail as jest.Mock; const mockSetStage = jest.fn(); - +jest.mock('react-router', () => ({ + useNavigate: () => mockedUseNavigate, +})); const clickSubmitButton = () => { userEvent.click(screen.getByRole('button', { name: 'Send feedback' })); }; diff --git a/app/src/components/blocks/feedbackForm/FeedbackForm.tsx b/app/src/components/blocks/feedbackForm/FeedbackForm.tsx index abc624a31..8e189abe1 100644 --- a/app/src/components/blocks/feedbackForm/FeedbackForm.tsx +++ b/app/src/components/blocks/feedbackForm/FeedbackForm.tsx @@ -11,6 +11,10 @@ import isEmail from 'validator/lib/isEmail'; import sendEmail from '../../../helpers/requests/sendEmail'; import { Button, Fieldset, Input, Radios, Textarea } from 'nhsuk-react-components'; import SpinnerButton from '../../generic/spinnerButton/SpinnerButton'; +import { routes } from '../../../types/generic/routes'; +import { useNavigate } from 'react-router-dom'; +import { errorToParams } from '../../../helpers/utils/errorToParams'; +import { AxiosError } from 'axios/index'; export type Props = { stage: SUBMISSION_STAGE; @@ -25,17 +29,18 @@ function FeedbackForm({ stage, setStage }: Props) { } = useForm({ reValidateMode: 'onSubmit', }); + const navigate = useNavigate(); const submit: SubmitHandler = async (formData) => { setStage(SUBMISSION_STAGE.Submitting); - - sendEmail(formData) - .then(() => { - setStage(SUBMISSION_STAGE.Successful); - }) - .catch((e) => { - setStage(SUBMISSION_STAGE.Failure); - }); + // add tests for failing and passing cases when real email service is implemented + try { + await sendEmail(formData); + setStage(SUBMISSION_STAGE.Successful); + } catch (e) { + const error = e as AxiosError; + navigate(routes.SERVER_ERROR + errorToParams(error)); + } }; const renameRefKey = (props: UseFormRegisterReturn, newRefKey: string) => { diff --git a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.test.tsx b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.test.tsx index 4b341c270..2cc690ef3 100644 --- a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.test.tsx +++ b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.test.tsx @@ -6,27 +6,25 @@ import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; import usePatient from '../../../helpers/hooks/usePatient'; import { LinkProps } from 'react-router-dom'; +import { routes } from '../../../types/generic/routes'; +import mock = jest.mock; const mockedUseNavigate = jest.fn(); -jest.mock('react-router', () => ({ - useNavigate: () => mockedUseNavigate, -})); +const mockedAxios = axios as jest.Mocked; +const mockedUsePatient = usePatient as jest.Mock; +const mockPdf = buildLgSearchResult(); +const mockPatient = buildPatientDetails(); +const mockSetStage = jest.fn(); +const mockDownloadStage = jest.fn(); jest.mock('react-router-dom', () => ({ __esModule: true, Link: (props: LinkProps) => , + useNavigate: () => mockedUseNavigate, })); jest.mock('axios'); jest.mock('../../../helpers/hooks/useBaseAPIHeaders'); jest.mock('../../../helpers/hooks/usePatient'); -const mockedAxios = axios as jest.Mocked; -const mockedUsePatient = usePatient as jest.Mock; - -const mockPdf = buildLgSearchResult(); -const mockPatient = buildPatientDetails(); -const mockSetStage = jest.fn(); -const mockDownloadStage = jest.fn(); - describe('LloydGeorgeDownloadAllStage', () => { beforeEach(() => { process.env.REACT_APP_ENVIRONMENT = 'jest'; @@ -97,6 +95,60 @@ describe('LloydGeorgeDownloadAllStage', () => { }); expect(screen.getByRole('heading', { name: 'Download complete' })).toBeInTheDocument(); }); + + it('navigates to Error page when zip lg record view complete but fail on delete', async () => { + window.HTMLAnchorElement.prototype.click = jest.fn(); + mockedAxios.get.mockImplementation(() => Promise.resolve({ data: mockPdf.presign_url })); + const errorResponse = { + response: { + status: 500, + data: { message: 'An error occurred', err_code: 'SP_1001' }, + }, + }; + mockedAxios.delete.mockImplementation(() => Promise.reject(errorResponse)); + + jest.useFakeTimers(); + + renderComponent({ deleteAfterDownload: true }); + + expect(screen.getByText('0% downloaded...')).toBeInTheDocument(); + expect(screen.queryByText('100% downloaded...')).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(screen.getByText('100% downloaded...')).toBeInTheDocument(); + }); + expect(screen.queryByText('0% downloaded...')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=SP_1001', + ); + }); + }); + + it('navigates to Error page when zip lg record view return 500', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'An error occurred', err_code: 'SP_1001' }, + }, + }; + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + jest.useFakeTimers(); + renderComponent(); + act(() => { + jest.advanceTimersByTime(500); + }); + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=SP_1001', + ); + }); + }); }); const renderComponent = (propsOverride?: Partial) => { diff --git a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx index f8e88caa3..899859264 100644 --- a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx +++ b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx @@ -8,7 +8,6 @@ import React, { useState, } from 'react'; import { Card } from 'nhsuk-react-components'; -import { Link } from 'react-router-dom'; import useBaseAPIHeaders from '../../../helpers/hooks/useBaseAPIHeaders'; import getPresignedUrlForZip from '../../../helpers/requests/getPresignedUrlForZip'; import { DOCUMENT_TYPE } from '../../../types/pages/UploadDocumentsPage/types'; @@ -18,6 +17,10 @@ import useBaseAPIUrl from '../../../helpers/hooks/useBaseAPIUrl'; import usePatient from '../../../helpers/hooks/usePatient'; import deleteAllDocuments from '../../../helpers/requests/deleteAllDocuments'; import { DOWNLOAD_STAGE } from '../../../types/generic/downloadStage'; +import { routes } from '../../../types/generic/routes'; +import { useNavigate, Link } from 'react-router-dom'; +import { errorToParams } from '../../../helpers/utils/errorToParams'; +import { AxiosError } from 'axios/index'; const FakeProgress = require('fake-progress'); @@ -50,6 +53,7 @@ function LloydGeorgeDownloadAllStage({ const [inProgress, setInProgress] = useState(true); const linkRef = useRef(null); const mounted = useRef(false); + const navigate = useNavigate(); const patientDetails = usePatient(); const nhsNumber = patientDetails?.nhsNumber ?? ''; @@ -103,9 +107,13 @@ function LloydGeorgeDownloadAllStage({ baseUrl, baseHeaders, }); - } catch (e) {} // This is fail and forget at this point in time. + } catch (e) { + navigate(routes.SERVER_ERROR + errorToParams(e as AxiosError)); + } // This is fail and forget at this point in time. } - } catch (e) {} + } catch (e) { + navigate(routes.SERVER_ERROR + errorToParams(e as AxiosError)); + } }; if (!mounted.current) { @@ -116,7 +124,15 @@ function LloydGeorgeDownloadAllStage({ const delayTimer = setTimeout(onPageLoad, timeToComplete + delay); setDelayTimer(delayTimer); } - }, [baseHeaders, baseUrl, intervalTimer, nhsNumber, progressTimer, deleteAfterDownload]); + }, [ + baseHeaders, + baseUrl, + intervalTimer, + nhsNumber, + progressTimer, + deleteAfterDownload, + navigate, + ]); return inProgress ? (
diff --git a/app/src/helpers/requests/sendEmail.ts b/app/src/helpers/requests/sendEmail.ts index 10b0538aa..1b030a8f7 100644 --- a/app/src/helpers/requests/sendEmail.ts +++ b/app/src/helpers/requests/sendEmail.ts @@ -4,8 +4,16 @@ const sendEmail = async (formData: FormData) => { // using console.log as a placeholder until we got the send email solution in place /* eslint-disable-next-line no-console */ console.log(`sending feedback from user by email: ${JSON.stringify(formData)}}`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - return { status: 200 }; + try { + await new Promise((resolve, reject) => + setTimeout(() => { + resolve({}); + }, 1000), + ); + return { status: 200 }; + } catch (e) { + throw e; + } }; export default sendEmail; diff --git a/app/src/helpers/utils/errorCodes.ts b/app/src/helpers/utils/errorCodes.ts new file mode 100644 index 000000000..f58e1f501 --- /dev/null +++ b/app/src/helpers/utils/errorCodes.ts @@ -0,0 +1,30 @@ +const errorCodes: { [key: string]: string } = { + CDR_5001: 'Internal error', + CDR_5002: 'Internal error', + DT_5001: 'Failed to resolve dynamodb table name for this document', + LR_5001: 'Server error', + LIN_5001: 'Unrecognised state value', + LIN_5002: 'Issue when contacting CIS2', + LIN_5003: 'Bad response from ODS API', + LIN_5004: 'Unable to remove used state value', + LIN_5005: 'Failed to find SSM parameter value for user role', + LIN_5006: 'Failed to find SSM parameter value for user role', + LIN_5007: 'Failed to find SSM parameter value for smartcard', + LIN_5008: 'Failed to find SSM parameter value for PCSE role', + LIN_5009: 'Failed to find SSM parameter value for GP role', + LIN_5010: 'SSM parameter values for PSCE ODS err_code may not exist', + DMS_5001: 'Failed to parse document reference from from DynamoDb response', + DMS_5002: 'Failed to create document manifest', + DMS_5003: 'Failed to create document manifest', + LGS_5001: 'Unable to retrieve documents for patient', + LGS_5002: 'Unable to return stitched pdf file due to internal error', + LGS_5003: 'Unable to retrieve documents for patient', + LGS_5004: 'Unable to retrieve documents for patient', + DRS_5001: 'An error occurred when searching for available documents', + DDS_5001: 'Failed to delete documents', + OUT_5001: 'Error logging user out', + ENV_5001: 'An error occurred due to missing environment variable', + GWY_5001: 'Failed to utilise AWS client/resource', +}; + +export default errorCodes; diff --git a/app/src/helpers/utils/errorToParams.test.ts b/app/src/helpers/utils/errorToParams.test.ts new file mode 100644 index 000000000..ef335d2a0 --- /dev/null +++ b/app/src/helpers/utils/errorToParams.test.ts @@ -0,0 +1,28 @@ +import { errorToParams } from './errorToParams'; +import { AxiosError } from 'axios'; + +describe('errorToParams util function', () => { + it('returns empty string param if error has no err_code', () => { + const errorResponse = { + response: { + status: 500, + data: { message: '500 Unknown Service Error.' }, + }, + }; + const error = errorResponse as AxiosError; + + expect(errorToParams(error)).toBe(''); + }); + + it('returns param with error code', () => { + const errorResponse = { + response: { + status: 500, + data: { message: '500 Unknown Service Error.', err_code: 'test' }, + }, + }; + const error = errorResponse as AxiosError; + + expect(errorToParams(error)).toBe('?errorCode=test'); + }); +}); diff --git a/app/src/helpers/utils/errorToParams.ts b/app/src/helpers/utils/errorToParams.ts new file mode 100644 index 000000000..828431867 --- /dev/null +++ b/app/src/helpers/utils/errorToParams.ts @@ -0,0 +1,7 @@ +import { ErrorResponse } from '../../types/generic/errorResponse'; +import { AxiosError } from 'axios'; + +export const errorToParams = (error: AxiosError) => { + const errorResponse = error.response?.data as ErrorResponse; + return errorResponse.err_code ? '?errorCode=' + errorResponse.err_code : ''; +}; diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx index ba4f5b5b3..c7ea75d8c 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx @@ -6,6 +6,7 @@ import { routes } from '../../types/generic/routes'; import axios from 'axios'; import usePatient from '../../helpers/hooks/usePatient'; import { LinkProps } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; const mockedUseNavigate = jest.fn(); jest.mock('react-router', () => ({ @@ -15,6 +16,7 @@ jest.mock('react-router-dom', () => ({ __esModule: true, Link: (props: LinkProps) => , useNavigate: () => jest.fn(), + useLocation: () => jest.fn(), })); jest.mock('../../helpers/hooks/useBaseAPIHeaders'); jest.mock('axios'); @@ -93,34 +95,43 @@ describe('', () => { ).not.toBeInTheDocument(); }); - it('displays a error messages when a document search fails', async () => { + it('displays a error messages when the call to document manifest fails', async () => { + mockedAxios.get.mockResolvedValue({ data: [buildSearchResult()] }); + const errorResponse = { response: { - status: 400, + status: 403, message: 'An error occurred', }, }; - mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); render(); - expect(await screen.findByRole('alert')).toBeInTheDocument(); + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + await waitFor(() => { + screen.getByRole('button', { name: 'Download All Documents' }); + }); + userEvent.click(screen.getByRole('button', { name: 'Download All Documents' })); + expect( - screen.queryByRole('button', { name: 'Download All Documents' }), - ).not.toBeInTheDocument(); + await screen.findByText('An error has occurred while preparing your download'), + ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: 'Delete All Documents' }), - ).not.toBeInTheDocument(); + screen.getByRole('button', { name: 'Download All Documents' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete All Documents' }), + ).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Start Again' })).toBeInTheDocument(); }); - - it('displays a error messages when the call to document manifest fails', async () => { + it('displays a error messages when the call to document manifest return 400', async () => { mockedAxios.get.mockResolvedValue({ data: [buildSearchResult()] }); const errorResponse = { response: { status: 400, - message: 'An error occurred', + data: { message: 'An error occurred', err_code: 'SP_1001' }, }, }; @@ -160,6 +171,45 @@ describe('', () => { userEvent.click(screen.getByRole('link', { name: 'Start Again' })); + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); + }); + }); + it('navigates to Error page when call to doc manifest return 500', async () => { + mockedAxios.get.mockResolvedValue({ data: [buildSearchResult()] }); + const errorResponse = { + response: { + status: 500, + data: { message: 'An error occurred', err_code: 'SP_1001' }, + }, + }; + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + act(() => { + render(); + }); + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Start Again' })).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=SP_1001', + ); + }); + }); + it('navigates to Start page when a document search fails', async () => { + const errorResponse = { + response: { + status: 403, + message: 'An error occurred', + }, + }; + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + render(); + await waitFor(() => { expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); }); diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index 7b551c88e..14eaf27a8 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx @@ -17,6 +17,7 @@ import { DOCUMENT_TYPE } from '../../types/pages/UploadDocumentsPage/types'; import usePatient from '../../helpers/hooks/usePatient'; import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; import ErrorBox from '../../components/layout/errorBox/ErrorBox'; +import { errorToParams } from '../../helpers/utils/errorToParams'; function DocumentSearchResultsPage() { const patientDetails = usePatient(); @@ -52,8 +53,11 @@ function DocumentSearchResultsPage() { const error = e as AxiosError; if (error.response?.status === 403) { navigate(routes.START); + } else if (error.response?.status && error.response?.status >= 500) { + navigate(routes.SERVER_ERROR + errorToParams(error)); + } else { + setSubmissionState(SUBMISSION_STATE.FAILED); } - setSubmissionState(SUBMISSION_STATE.FAILED); } }; if (!mounted.current) { diff --git a/app/src/pages/feedbackPage/FeedbackPage.test.tsx b/app/src/pages/feedbackPage/FeedbackPage.test.tsx index 12c92e9c2..1e78ed0c0 100644 --- a/app/src/pages/feedbackPage/FeedbackPage.test.tsx +++ b/app/src/pages/feedbackPage/FeedbackPage.test.tsx @@ -8,7 +8,9 @@ import { fillInForm } from '../../helpers/test/formUtils'; jest.mock('../../helpers/requests/sendEmail'); const mockSendEmail = sendEmail as jest.Mock; - +jest.mock('react-router-dom', () => ({ + useNavigate: () => jest.fn(), +})); describe('', () => { beforeEach(() => { process.env.REACT_APP_ENVIRONMENT = 'jest'; diff --git a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx index 2e797c277..0601d08ed 100644 --- a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx +++ b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx @@ -1,10 +1,16 @@ import { render, screen, waitFor } from '@testing-library/react'; import LloydGeorgeRecordPage from './LloydGeorgeRecordPage'; -import { buildPatientDetails, buildLgSearchResult } from '../../helpers/test/testBuilders'; +import { + buildPatientDetails, + buildLgSearchResult, + buildSearchResult, +} from '../../helpers/test/testBuilders'; import { getFormattedDate } from '../../helpers/utils/formatDate'; import axios from 'axios'; import formatFileSize from '../../helpers/utils/formatFileSize'; import usePatient from '../../helpers/hooks/usePatient'; +import { act } from 'react-dom/test-utils'; +import { routes } from '../../types/generic/routes'; jest.mock('axios'); jest.mock('../../helpers/hooks/usePatient'); @@ -102,4 +108,26 @@ describe('LloydGeorgeRecordPage', () => { ).toBeInTheDocument(); expect(screen.getByText('File format: PDF')).toBeInTheDocument(); }); + it('navigates to Error page when call to lg record view return 500', async () => { + mockAxios.get.mockResolvedValue({ data: [buildSearchResult()] }); + const errorResponse = { + response: { + status: 500, + data: { message: 'An error occurred', err_code: 'SP_1001' }, + }, + }; + mockAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + act(() => { + render(); + }); + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Start Again' })).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SERVER_ERROR + '?errorCode=SP_1001'); + }); + }); }); diff --git a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx index 8dc1a39ba..6d5d6c08d 100644 --- a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx +++ b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx @@ -14,6 +14,9 @@ import { AxiosError } from 'axios'; import useRole from '../../helpers/hooks/useRole'; import useIsBSOL from '../../helpers/hooks/useIsBSOL'; import { REPOSITORY_ROLE } from '../../types/generic/authRole'; +import { routes } from '../../types/generic/routes'; +import { useNavigate } from 'react-router'; +import { errorToParams } from '../../helpers/utils/errorToParams'; function LloydGeorgeRecordPage() { const patientDetails = usePatient(); @@ -26,6 +29,7 @@ function LloydGeorgeRecordPage() { const baseHeaders = useBaseAPIHeaders(); const mounted = useRef(false); const [stage, setStage] = useState(LG_RECORD_STAGE.RECORD); + const navigate = useNavigate(); const role = useRole(); const isBSOL = useIsBSOL(); @@ -55,6 +59,8 @@ function LloydGeorgeRecordPage() { setDownloadStage(DOWNLOAD_STAGE.TIMEOUT); } else if (error.response?.status === 404) { setDownloadStage(DOWNLOAD_STAGE.NO_RECORDS); + } else if (error.response?.status && error.response?.status >= 500) { + navigate(routes.SERVER_ERROR + errorToParams(error)); } else { setDownloadStage(DOWNLOAD_STAGE.FAILED); } @@ -76,6 +82,7 @@ function LloydGeorgeRecordPage() { setLastUpdated, setNumberOfFiles, setTotalFileSizeInByte, + navigate, ]); switch (stage) { diff --git a/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx b/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx index fb6dbb1f3..1c68fb3fa 100644 --- a/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx +++ b/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx @@ -121,7 +121,7 @@ describe('PatientSearchPage', () => { const errorResponse = { response: { status: 500, - message: '500 Unknown Service Error.', + data: { message: '500 Unknown Service Error.', err_code: 'test' }, }, }; mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); @@ -192,6 +192,27 @@ describe('PatientSearchPage', () => { expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); }); }); + + it('navigates to server error page when API returns 5XX', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: '500 Unknown Service Error.', err_code: 'test' }, + }, + }; + + mockedAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + renderPatientSearchPage(); + userEvent.type(screen.getByRole('textbox', { name: 'Enter NHS number' }), '9000000000'); + userEvent.click(screen.getByRole('button', { name: 'Search' })); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?errorCode=test', + ); + }); + }); }); describe('Validation', () => { diff --git a/app/src/pages/patientSearchPage/PatientSearchPage.tsx b/app/src/pages/patientSearchPage/PatientSearchPage.tsx index ff8c403b8..f44fd965f 100644 --- a/app/src/pages/patientSearchPage/PatientSearchPage.tsx +++ b/app/src/pages/patientSearchPage/PatientSearchPage.tsx @@ -17,6 +17,7 @@ import { buildPatientDetails } from '../../helpers/test/testBuilders'; import { isMock } from '../../helpers/utils/isLocal'; import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; +import { errorToParams } from '../../helpers/utils/errorToParams'; export const incorrectFormatMessage = "Enter patient's 10 digit NHS number"; @@ -70,6 +71,8 @@ function PatientSearchPage() { navigate(routes.START); } else if (error.response?.status === 404) { setInputError('Sorry, patient data not found.'); + } else { + navigate(routes.SERVER_ERROR + errorToParams(error)); } setStatusCode(error.response?.status ?? null); setSubmissionState(SEARCH_STATES.FAILED); diff --git a/app/src/pages/serverErrorPage/ServerErrorPage.test.tsx b/app/src/pages/serverErrorPage/ServerErrorPage.test.tsx new file mode 100644 index 000000000..4e3fbc43f --- /dev/null +++ b/app/src/pages/serverErrorPage/ServerErrorPage.test.tsx @@ -0,0 +1,100 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import ServerErrorPage from './ServerErrorPage'; +import { act } from 'react-dom/test-utils'; +import userEvent from '@testing-library/user-event'; + +const mockedUseNavigate = jest.fn(); + +jest.mock('react-router', () => ({ + useNavigate: () => mockedUseNavigate, + useLocation: () => jest.fn(), +})); + +describe('ServerErrorPage', () => { + beforeEach(() => { + process.env.REACT_APP_ENVIRONMENT = 'jest'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders page content with default error message and id when there is no error code', () => { + render(); + + expect( + screen.getByRole('heading', { + name: 'Sorry, there is a problem with the service', + }), + ).toBeInTheDocument(); + expect(screen.getByText('An unknown error has occurred.')).toBeInTheDocument(); + expect( + screen.getByText( + "Try again by returning to the previous page. You'll need to enter any information you submitted again.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Return to previous page', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + name: 'If this error keeps appearing', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: 'Contact the NHS National Service Desk', + }), + ).toBeInTheDocument(); + expect(screen.getByText('UNKNOWN_ERROR')).toBeInTheDocument(); + }); + + it('renders page content with error message and id when there is a valid error code', () => { + jest.spyOn(URLSearchParams.prototype, 'get').mockReturnValue('CDR_5001'); + render(); + + expect( + screen.getByRole('heading', { + name: 'Sorry, there is a problem with the service', + }), + ).toBeInTheDocument(); + expect(screen.getByText('Internal error')).toBeInTheDocument(); + expect(screen.queryByText('An unknown error has occurred.')).not.toBeInTheDocument(); + expect(screen.getByText('CDR_5001')).toBeInTheDocument(); + expect(screen.queryByText('UNKNOWN_ERROR')).not.toBeInTheDocument(); + }); + + it('renders page content with default error message and id when there is an invalid error code', () => { + jest.spyOn(URLSearchParams.prototype, 'get').mockReturnValue('RH_77'); + render(); + + expect( + screen.getByRole('heading', { + name: 'Sorry, there is a problem with the service', + }), + ).toBeInTheDocument(); + expect(screen.getByText('An unknown error has occurred.')).toBeInTheDocument(); + expect(screen.getByText('UNKNOWN_ERROR')).toBeInTheDocument(); + expect(screen.queryByText('RH_77')).not.toBeInTheDocument(); + }); + }); + + describe('Navigation', () => { + it('navigates user to previous page when return home is clicked', async () => { + render(); + const returnButtonLink = screen.getByRole('button', { + name: 'Return to previous page', + }); + expect(returnButtonLink).toBeInTheDocument(); + act(() => { + userEvent.click(returnButtonLink); + }); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith(-2); + }); + }); + }); +}); diff --git a/app/src/pages/serverErrorPage/ServerErrorPage.tsx b/app/src/pages/serverErrorPage/ServerErrorPage.tsx new file mode 100644 index 000000000..6791c453f --- /dev/null +++ b/app/src/pages/serverErrorPage/ServerErrorPage.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from 'react-router'; +import { ButtonLink } from 'nhsuk-react-components'; +import React from 'react'; +import errorCodes from '../../helpers/utils/errorCodes'; +import { useSearchParams } from 'react-router-dom'; + +const ServerErrorPage = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const errorCode = searchParams.get('errorCode'); + + const defaultMessage = 'An unknown error has occurred.'; + const defaultErrorId = 'UNKNOWN_ERROR'; + + const errorMessage = + errorCode && !!errorCodes[errorCode] ? errorCodes[errorCode] : defaultMessage; + const errorId = errorCode && !!errorCodes[errorCode] ? errorCode : defaultErrorId; + + return ( + <> +

Sorry, there is a problem with the service

+

{errorMessage}

+

+ Try again by returning to the previous page. You'll need to enter any information + you submitted again. +

+ { + e.preventDefault(); + navigate(-2); + }} + > + Return to previous page + + +

If this error keeps appearing

+

+ + Contact the NHS National Service Desk + {' '} + or call 0300 303 5678 +

+ +

+ When contacting the service desk, quote this error code as reference:{' '} + {errorId} +

+ + ); +}; +export default ServerErrorPage; diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx index 59e9b63db..cabc3102d 100644 --- a/app/src/router/AppRouter.tsx +++ b/app/src/router/AppRouter.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { BrowserRouter as Router, Routes as Switch, Route, Outlet } from 'react-router-dom'; +import { BrowserRouter as Router, Outlet, Route, Routes as Switch } from 'react-router-dom'; import Layout from '../components/layout/Layout'; -import { ROUTE_TYPE, route, routes } from '../types/generic/routes'; +import { route, ROUTE_TYPE, routes } from '../types/generic/routes'; import StartPage from '../pages/startPage/StartPage'; import AuthCallbackPage from '../pages/authCallbackPage/AuthCallbackPage'; import NotFoundPage from '../pages/notFoundPage/NotFoundPage'; @@ -20,6 +20,7 @@ import RoleGuard from './guards/roleGuard/RoleGuard'; import HomePage from '../pages/homePage/HomePage'; import UnauthorisedLoginPage from '../pages/unauthorisedLoginPage/UnauthorisedLoginPage'; import FeedbackPage from '../pages/feedbackPage/FeedbackPage'; +import ServerErrorPage from '../pages/serverErrorPage/ServerErrorPage'; const { START, @@ -29,6 +30,7 @@ const { UNAUTHORISED, UNAUTHORISED_LOGIN, AUTH_ERROR, + SERVER_ERROR, FEEDBACK, LOGOUT, DOWNLOAD_DOCUMENTS, @@ -68,6 +70,10 @@ export const routeMap: Routes = { page: , type: ROUTE_TYPE.PUBLIC, }, + [SERVER_ERROR]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, // Auth guard routes [LOGOUT]: { page: , diff --git a/app/src/types/generic/errorResponse.ts b/app/src/types/generic/errorResponse.ts new file mode 100644 index 000000000..ea2a96222 --- /dev/null +++ b/app/src/types/generic/errorResponse.ts @@ -0,0 +1,4 @@ +export type ErrorResponse = { + message: string; + err_code: string; +}; diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 00c3afd19..8babed87a 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -8,6 +8,7 @@ export enum routes { UNAUTHORISED = '/unauthorised', AUTH_ERROR = '/auth-error', UNAUTHORISED_LOGIN = '/unauthorised-login', + SERVER_ERROR = '/server-error', LOGOUT = '/logout', FEEDBACK = '/feedback', SEARCH_PATIENT = '/search/patient', diff --git a/lambdas/services/bulk_upload_service.py b/lambdas/services/bulk_upload_service.py index e3b58c7bb..699b4aea0 100644 --- a/lambdas/services/bulk_upload_service.py +++ b/lambdas/services/bulk_upload_service.py @@ -25,8 +25,8 @@ from utils.lloyd_george_validator import ( LGInvalidFilesException, getting_patient_info_from_pds, - validate_lg_file_names, validate_filename_with_patient_details, + validate_lg_file_names, ) from utils.request_context import request_context from utils.unicode_utils import ( diff --git a/lambdas/tests/unit/services/test_bulk_upload_service.py b/lambdas/tests/unit/services/test_bulk_upload_service.py index e27f56c8c..6ee1723cf 100644 --- a/lambdas/tests/unit/services/test_bulk_upload_service.py +++ b/lambdas/tests/unit/services/test_bulk_upload_service.py @@ -83,7 +83,9 @@ def mock_pds_service(mocker): @pytest.fixture def mock_pds_validation(mocker): - yield mocker.patch("services.bulk_upload_service.validate_filename_with_patient_details") + yield mocker.patch( + "services.bulk_upload_service.validate_filename_with_patient_details" + ) @pytest.fixture diff --git a/lambdas/tests/unit/utils/test_lloyd_george_validator.py b/lambdas/tests/unit/utils/test_lloyd_george_validator.py index 45a4a377e..46ce63c01 100644 --- a/lambdas/tests/unit/utils/test_lloyd_george_validator.py +++ b/lambdas/tests/unit/utils/test_lloyd_george_validator.py @@ -24,9 +24,9 @@ extract_info_from_filename, getting_patient_info_from_pds, validate_file_name, + validate_filename_with_patient_details, validate_lg_file_names, validate_lg_file_type, - validate_filename_with_patient_details, )