diff --git a/app/package-lock.json b/app/package-lock.json index 40dbd47e7e..6ac169f513 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10502,6 +10502,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-parser": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", @@ -13323,9 +13339,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -14313,6 +14329,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -14967,7 +14984,7 @@ "license": "MIT", "dependencies": { "@zeit/schemas": "2.36.0", - "ajv": "8.12.0", + "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", @@ -14994,7 +15011,7 @@ "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", - "minimatch": "3.1.2", + "minimatch": "3.1.3", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" @@ -15031,15 +15048,15 @@ } }, "node_modules/serve/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -16256,6 +16273,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/app/src/components/blocks/_admin/reviewDetailsAssessmentStage/ReviewDetailsAssessmentStage.test.tsx b/app/src/components/blocks/_admin/reviewDetailsAssessmentStage/ReviewDetailsAssessmentStage.test.tsx index 3a4248f28f..d9d25c1ee9 100644 --- a/app/src/components/blocks/_admin/reviewDetailsAssessmentStage/ReviewDetailsAssessmentStage.test.tsx +++ b/app/src/components/blocks/_admin/reviewDetailsAssessmentStage/ReviewDetailsAssessmentStage.test.tsx @@ -890,7 +890,7 @@ describe('ReviewDetailsAssessmentStage', () => { it('navigates to SESSION_EXPIRED when getReviewById returns 403', async () => { const user = userEvent.setup(); const mockGetReviewById = vi.spyOn(getReviewsModule, 'getReviewById'); - mockGetReviewById.mockRejectedValue({ code: '403' }); + mockGetReviewById.mockRejectedValue({ response: { status: 403 } }); render( { expectedRequest, ); }); + + it('should navigate to session expired page if patchReview throws 403', async () => { + vi.mocked(mockPatchReview).mockRejectedValueOnce({ response: { status: 403 } }); + + const reviewData = new ReviewDetails( + 'test-review-id', + DOCUMENT_TYPE.LLOYD_GEORGE, + '2023-10-01T00:00:00Z', + 'test', + '2023-10-01T00:00:00Z', + 'rejected', + '1', + mockPatientDetails.nhsNumber, + ); + mockReviewUploadDocuments[0].ref = 'doc-ref-id'; + + render( + , + ); + + await waitFor(() => { + expect(mockPatchReview).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); + }); + }); + + it('should navigate to server error page if patchReview throws 500', async () => { + vi.mocked(mockPatchReview).mockRejectedValueOnce({ response: { status: 500 } }); + + const reviewData = new ReviewDetails( + 'test-review-id', + DOCUMENT_TYPE.LLOYD_GEORGE, + '2023-10-01T00:00:00Z', + 'test', + '2023-10-01T00:00:00Z', + 'rejected', + '1', + mockPatientDetails.nhsNumber, + ); + mockReviewUploadDocuments[0].ref = 'doc-ref-id'; + + render( + , + ); + + await waitFor(() => { + expect(mockPatchReview).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining(routes.SERVER_ERROR), + ); + }); + }); }); }); diff --git a/app/src/components/blocks/_admin/reviewDetailsCompleteStage/ReviewDetailsCompleteStage.tsx b/app/src/components/blocks/_admin/reviewDetailsCompleteStage/ReviewDetailsCompleteStage.tsx index e48e7c1f29..41c602604b 100644 --- a/app/src/components/blocks/_admin/reviewDetailsCompleteStage/ReviewDetailsCompleteStage.tsx +++ b/app/src/components/blocks/_admin/reviewDetailsCompleteStage/ReviewDetailsCompleteStage.tsx @@ -85,7 +85,7 @@ const ReviewDetailsCompleteStage = ({ setLoading(false); } catch (e) { const error = e as AxiosError; - if (error.code === '403') { + if (error.response?.status === 403) { navigate(routes.SESSION_EXPIRED); } else { navigate(routes.SERVER_ERROR + errorToParams(error)); diff --git a/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.test.tsx b/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.test.tsx index ac469efabe..422e8d0f34 100644 --- a/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.test.tsx +++ b/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import ReviewsDetailsPageComponent from './ReviewsDetailsStage'; import { runAxeTest } from '../../../../helpers/test/axeTestHelper'; import { buildPatientDetails } from '../../../../helpers/test/testBuilders'; @@ -16,27 +16,24 @@ import { DOCUMENT_UPLOAD_STATE, } from '../../../../types/pages/UploadDocumentsPage/types'; import { NHS_NUMBER_UNKNOWN } from '../../../../helpers/constants/numbers'; - -const mockNavigate = vi.fn(); -const mockSetPatientDetails = vi.fn(); -const mockUsePatientDetailsContext = vi.fn(); -const mockUseSessionContext = vi.fn(); +import * as handlePatientSearchModule from '../../../../helpers/utils/handlePatientSearch'; +import { routes } from '../../../../types/generic/routes'; vi.mock('react-router-dom', async (): Promise => { const actual = await vi.importActual('react-router-dom'); return { ...actual, - useNavigate: () => mockNavigate, + useNavigate: (): Mock => mockNavigate, useParams: (): { reviewId: string } => ({ reviewId: 'test-review-123' }), }; }); vi.mock('../../../../providers/patientProvider/PatientProvider', () => ({ - usePatientDetailsContext: (): unknown => mockUsePatientDetailsContext(), + usePatientDetailsContext: (): Mock => mockUsePatientDetailsContext(), })); vi.mock('../../../../providers/sessionProvider/SessionProvider', () => ({ - useSessionContext: (): unknown => mockUseSessionContext(), + useSessionContext: (): Mock => mockUseSessionContext(), })); vi.mock('../../../../helpers/hooks/useRole', () => ({ @@ -69,14 +66,17 @@ vi.mock('../../../../helpers/requests/getReviews', () => ({ }), })); -vi.mock('../../../../helpers/utils/handlePatientSearch', () => ({ - handleSearch: vi.fn().mockResolvedValue(undefined), -})); +vi.mock('../../../../helpers/utils/handlePatientSearch'); vi.mock('../../../../helpers/utils/waitForSeconds', () => ({ default: vi.fn().mockResolvedValue(undefined), })); +const mockNavigate = vi.fn(); +const mockSetPatientDetails = vi.fn(); +const mockUsePatientDetailsContext = vi.fn(); +const mockUseSessionContext = vi.fn(); + const renderComponent = (reviewData?: ReviewDetails, reviewSnoMed?: DOCUMENT_TYPE): void => { const currentReviewData = reviewData ?? @@ -751,7 +751,7 @@ describe('ReviewDetailsStage', () => { }); it('navigates to SESSION_EXPIRED on 403 error', async () => { - const mockLoadReviewData = vi.fn().mockRejectedValue({ code: '403' }); + const mockLoadReviewData = vi.fn().mockRejectedValue({ response: { status: 403 } }); render( { expect(screen.getByText("Van Der Berg, O'Brien")).toBeInTheDocument(); }); }); + + it('navigates to session expired page when patient search returns 403', async () => { + vi.spyOn(handlePatientSearchModule, 'handleSearch').mockRejectedValueOnce({ + response: { status: 403 }, + }); + + renderComponent(mockReviewData); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); + }); + }); + + it('navigates to server error page when patient search returns 500', async () => { + vi.spyOn(handlePatientSearchModule, 'handleSearch').mockRejectedValueOnce({ + response: { status: 500 }, + }); + + renderComponent(mockReviewData); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining(routes.SERVER_ERROR), + ); + }); + }); }); describe('Review Data Handling', () => { diff --git a/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.tsx b/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.tsx index 11c1d1bae9..cb4e3256b8 100644 --- a/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.tsx +++ b/app/src/components/blocks/_admin/reviewsDetailsStage/ReviewsDetailsStage.tsx @@ -65,6 +65,7 @@ const ReviewsDetailsStage = ({ const [session] = useSessionContext(); const [showError, setShowError] = useState(false); const errorSummaryRef = useRef(null); + const fetchingPatientDetailsRef = useRef(false); const isFetchingReviewDetailsRef = useRef(false); const baseUrl = useBaseAPIUrl(); @@ -152,9 +153,9 @@ const ReviewsDetailsStage = ({ setisLoadingPatientDetails(false); return; } + const getPatientDetails = async (): Promise => { - if (!isFetchingReviewDetailsRef.current) { - isFetchingReviewDetailsRef.current = true; + try { await handlePatientSearch({ nhsNumber: reviewData.nhsNumber, setSearchingState: () => {}, @@ -169,16 +170,27 @@ const ReviewsDetailsStage = ({ featureFlags: config.featureFlags, }); setisLoadingPatientDetails(false); + } catch (error) { + const err = error as AxiosError; + if (err.response?.status === 403) { + navigate(routes.SESSION_EXPIRED); + } else { + navigate(routes.SERVER_ERROR + errorToParams(err)); + } } }; - getPatientDetails(); + + if (!fetchingPatientDetailsRef.current) { + fetchingPatientDetailsRef.current = true; + getPatientDetails(); + } }, [reviewId]); useEffect(() => { const loadData = async (): Promise => { let retryCount = 0; - const maxRetries = 5; - const retryDelayMs = 1000; + const maxRetries = 10; + const retryDelayMs = 3; while (retryCount < maxRetries) { try { @@ -199,7 +211,7 @@ const ReviewsDetailsStage = ({ await waitForSeconds(retryDelayMs); } else { const error = e as AxiosError; - if (error.code === '403') { + if (error.response?.status === 403) { navigate(routes.SESSION_EXPIRED); return; } @@ -209,7 +221,11 @@ const ReviewsDetailsStage = ({ } } }; - loadData(); + + if (!isFetchingReviewDetailsRef.current) { + isFetchingReviewDetailsRef.current = true; + loadData(); + } }, [patientDetails, setPatientDetails]); const { register, handleSubmit } = useForm({ diff --git a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx index 9822c64010..1ce685bed6 100644 --- a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx +++ b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.test.tsx @@ -637,7 +637,7 @@ describe('ReviewsPage', () => { describe('Error Handling', () => { it('navigates to session expired when search returns 403', async () => { - mockGetReviews.mockRejectedValueOnce({ code: '403' }); + mockGetReviews.mockRejectedValueOnce({ response: { status: 403 } }); renderComponent(); await waitFor(() => { diff --git a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx index 7be759af9b..f8fdeb1a47 100644 --- a/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx +++ b/app/src/components/blocks/_admin/reviewsPage/ReviewsPage.tsx @@ -162,7 +162,7 @@ export const ReviewsPage = ({ setReviewData }: ReviewsPageProps): React.JSX.Elem } } catch (e) { const error = e as AxiosError; - if (error.code === '403') { + if (error.response?.status === 403) { navigate(routes.SESSION_EXPIRED); return; } diff --git a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.test.tsx b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.test.tsx index b61700e048..28677ef214 100644 --- a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.test.tsx +++ b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.test.tsx @@ -20,6 +20,7 @@ import PatientDetailsProvider from '../../../../providers/patientProvider/Patien import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; import { formatNhsNumber } from '../../../../helpers/utils/formatNhsNumber'; import { getFormattedDate } from '../../../../helpers/utils/formatDate'; +import postPatientAccessAudit from '../../../../helpers/requests/postPatientAccessAudit'; const mockedUseNavigate = vi.fn(); vi.mock('react-router-dom', async () => ({ @@ -31,16 +32,16 @@ vi.mock('../../../../helpers/hooks/useRole'); vi.mock('../../../../helpers/hooks/useBaseAPIUrl'); vi.mock('../../../../helpers/hooks/useBaseAPIHeaders'); vi.mock('../../../../helpers/hooks/usePatient'); -vi.mock('../../../../helpers/requests/postPatientAccessAudit', () => ({ - default: vi.fn().mockReturnValue({ response: { status: 200 } }), -})); +vi.mock('../../../../helpers/requests/postPatientAccessAudit'); const mockedUseRole = useRole as Mock; const mockedUsePatient = usePatient as Mock; +const mockedPostPatientAccessAudit = postPatientAccessAudit as Mock; describe('DeceasedPatientAccessAudit', () => { beforeEach(() => { import.meta.env.VITE_ENVIRONMENT = 'vitest'; + mockedPostPatientAccessAudit.mockResolvedValue({ response: { status: 200 } }); }); describe('Rendering', () => { @@ -178,6 +179,43 @@ describe('DeceasedPatientAccessAudit', () => { expect(mockedUseNavigate).toHaveBeenCalledWith(routes.LLOYD_GEORGE); }); }); + + it('should navigate to session expired page when user session has expired', async () => { + const mockPatientDetails = buildPatientDetails(); + mockedUsePatient.mockReturnValue(mockPatientDetails); + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); + vi.mocked(postPatientAccessAudit).mockRejectedValueOnce({ response: { status: 403 } }); + + renderDeceasedPatientAccessAudit(); + + await userEvent.click( + screen.getByTestId(`reason-checkbox-${DeceasedAccessAuditReasons.familyRequest}`), + ); + await userEvent.click(screen.getByTestId('form-submit-button')); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); + }); + }); + + it('should navigate to server error page when server returns an error', async () => { + const mockPatientDetails = buildPatientDetails(); + mockedUsePatient.mockReturnValue(mockPatientDetails); + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); + vi.mocked(postPatientAccessAudit).mockRejectedValueOnce({ response: { status: 500 } }); + renderDeceasedPatientAccessAudit(); + + await userEvent.click( + screen.getByTestId(`reason-checkbox-${DeceasedAccessAuditReasons.familyRequest}`), + ); + await userEvent.click(screen.getByTestId('form-submit-button')); + + await waitFor(() => { + expect(mockedUseNavigate).toHaveBeenCalledWith( + expect.stringContaining(routes.SERVER_ERROR), + ); + }); + }); }); }); diff --git a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx index 51a08db208..5a4776dd52 100644 --- a/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx +++ b/app/src/components/blocks/_patientAccessAudit/deceasedPatientAccessAudit/DeceasedPatientAccessAudit.tsx @@ -159,7 +159,7 @@ const DeceasedPatientAccessAudit = (): React.JSX.Element => { if (isMock(error)) { handleSuccess(accessAuditData); - } else if (error.code === '403') { + } else if (error.response?.status === 403) { navigate(routes.SESSION_EXPIRED); } else { navigate(routes.SERVER_ERROR + errorToParams(error));