diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx
index dd5c163e6..a66c96d61 100644
--- a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx
+++ b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.test.tsx
@@ -1,4 +1,6 @@
-import { render, screen, waitFor } from '@testing-library/react';
+// need to use happy-dom for this test file as jsdom doesn't support DOMMatrix and scrollIntoView
+// @vitest-environment happy-dom
+import { render, screen, waitFor, fireEvent, RenderResult } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import ReviewDetailsDocumentSelectStage from './ReviewDetailsDocumentSelectStage';
@@ -10,6 +12,8 @@ import {
UploadDocument,
} from '../../../../types/pages/UploadDocumentsPage/types';
import { routeChildren } from '../../../../types/generic/routes';
+import { getDocument } from 'pdfjs-dist';
+import { PDF_PARSING_ERROR_TYPE } from '../../../../helpers/utils/fileUploadErrorMessages';
const mockNavigate = vi.fn();
@@ -49,7 +53,7 @@ vi.mock('react-router-dom', () => ({
}));
describe('ReviewDetailsDocumentSelectStage', () => {
- const testReviewSnoMed: DOCUMENT_TYPE = DOCUMENT_TYPE.LLOYD_GEORGE;
+ const testReviewSnomed: DOCUMENT_TYPE = DOCUMENT_TYPE.LLOYD_GEORGE;
let mockReviewData: ReviewDetails;
let mockDocuments: UploadDocument[];
@@ -60,7 +64,7 @@ describe('ReviewDetailsDocumentSelectStage', () => {
mockReviewData = new ReviewDetails(
'test-review-id',
- testReviewSnoMed,
+ testReviewSnomed,
'2024-01-01T12:00:00Z',
'Test Uploader',
'2024-01-01T12:00:00Z',
@@ -74,39 +78,35 @@ describe('ReviewDetailsDocumentSelectStage', () => {
mockSetDocuments = vi.fn() as SetUploadDocuments;
});
+ const renderApp = (props?: {
+ reviewData?: ReviewDetails | null;
+ documents?: UploadDocument[];
+ setDocuments?: SetUploadDocuments;
+ }): RenderResult => {
+ const defaultProps = {
+ reviewData: mockReviewData,
+ documents: mockDocuments,
+ setDocuments: mockSetDocuments,
+ };
+
+ return render();
+ };
+
describe('Rendering', () => {
it('shows spinner when reviewData is null', () => {
- render(
- ,
- );
+ renderApp({ reviewData: null });
expect(screen.getByTestId('mock-spinner')).toBeInTheDocument();
});
it('shows spinner when files is null', () => {
- render(
- ,
- );
+ renderApp({ reviewData: { ...mockReviewData, files: null } as any });
expect(screen.getByTestId('mock-spinner')).toBeInTheDocument();
});
it('shows spinner when documents are not initialised', () => {
- render(
- ,
- );
+ renderApp();
expect(screen.getByTestId('mock-spinner')).toBeInTheDocument();
});
@@ -120,22 +120,14 @@ describe('ReviewDetailsDocumentSelectStage', () => {
file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
progress: 0,
- docType: testReviewSnoMed,
+ docType: testReviewSnomed,
attempts: 0,
numPages: 1,
validated: false,
},
];
- const { rerender } = render(
- ,
- );
-
- rerender(
+ render(
{
}),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
progress: 0,
- docType: testReviewSnoMed,
+ docType: testReviewSnomed,
attempts: 0,
numPages: 1,
validated: false,
},
];
- const { rerender } = render(
- ,
- );
-
- rerender(
- ,
- );
+ renderApp({ documents: testDocuments });
await waitFor(() => {
expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
@@ -200,28 +178,14 @@ describe('ReviewDetailsDocumentSelectStage', () => {
file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
progress: 0,
- docType: testReviewSnoMed,
+ docType: testReviewSnomed,
attempts: 0,
numPages: 1,
validated: false,
},
];
- const { rerender } = render(
- ,
- );
-
- rerender(
- ,
- );
+ renderApp({ documents: testDocuments });
await waitFor(() => {
expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
@@ -243,28 +207,14 @@ describe('ReviewDetailsDocumentSelectStage', () => {
file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
progress: 0,
- docType: testReviewSnoMed,
+ docType: testReviewSnomed,
attempts: 0,
numPages: 1,
validated: false,
},
];
- const { rerender } = render(
- ,
- );
-
- rerender(
- ,
- );
+ renderApp({ documents: testDocuments });
await waitFor(() => {
expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
@@ -287,7 +237,7 @@ describe('ReviewDetailsDocumentSelectStage', () => {
const user = userEvent.setup();
const customReviewData = new ReviewDetails(
'custom-id',
- testReviewSnoMed,
+ testReviewSnomed,
'2024-01-01T12:00:00Z',
'Test Uploader',
'2024-01-01T12:00:00Z',
@@ -303,28 +253,14 @@ describe('ReviewDetailsDocumentSelectStage', () => {
file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
state: DOCUMENT_UPLOAD_STATE.SELECTED,
progress: 0,
- docType: testReviewSnoMed,
+ docType: testReviewSnomed,
attempts: 0,
numPages: 1,
validated: false,
},
];
- const { rerender } = render(
- ,
- );
-
- rerender(
- ,
- );
+ renderApp({ reviewData: customReviewData, documents: testDocuments });
await waitFor(() => {
expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
@@ -338,4 +274,92 @@ describe('ReviewDetailsDocumentSelectStage', () => {
});
});
});
+
+ describe('Error handling', () => {
+ const errorCases = [
+ ['password protected file', PDF_PARSING_ERROR_TYPE.PASSWORD_MISSING],
+ ['invalid PDF structure', PDF_PARSING_ERROR_TYPE.INVALID_PDF_STRUCTURE],
+ ['empty PDF', PDF_PARSING_ERROR_TYPE.EMPTY_PDF],
+ ];
+
+ it.each(errorCases)(
+ 'navigates to admin file errors page when user selects a %s',
+ async (_description, errorType) => {
+ const testDocuments: UploadDocument[] = [
+ {
+ id: 'test-id',
+ file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
+ state: DOCUMENT_UPLOAD_STATE.SELECTED,
+ progress: 0,
+ docType: testReviewSnomed,
+ attempts: 0,
+ numPages: 1,
+ validated: false,
+ },
+ ];
+
+ renderApp({ documents: testDocuments });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
+ });
+
+ // Set up mock to throw error AFTER component is ready
+ vi.mocked(getDocument).mockImplementationOnce(() => {
+ throw new Error(errorType as string);
+ });
+
+ const errorFile = new File(['test'], 'error-file.pdf', { type: 'application/pdf' });
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files: [errorFile] },
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ routeChildren.ADMIN_REVIEW_FILE_ERRORS.replaceAll(
+ ':reviewId',
+ 'test-review-id.1',
+ ),
+ );
+ });
+ },
+ );
+
+ it('navigates to admin file errors page when user selects a non-PDF file', async () => {
+ const testDocuments: UploadDocument[] = [
+ {
+ id: 'test-id',
+ file: new File(['test'], 'test.pdf', { type: 'application/pdf' }),
+ state: DOCUMENT_UPLOAD_STATE.SELECTED,
+ progress: 0,
+ docType: testReviewSnomed,
+ attempts: 0,
+ numPages: 1,
+ validated: false,
+ },
+ ];
+
+ renderApp({ documents: testDocuments });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('mock-spinner')).not.toBeInTheDocument();
+ });
+
+ const nonPdfFile = new File(['test'], 'nonPdfFile.txt', { type: 'text/plain' });
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files: [nonPdfFile] },
+ });
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ routeChildren.ADMIN_REVIEW_FILE_ERRORS.replaceAll(
+ ':reviewId',
+ 'test-review-id.1',
+ ),
+ );
+ });
+ });
+ });
});
diff --git a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx
index 045a38e4b..3dfa3bd04 100644
--- a/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx
+++ b/app/src/components/blocks/_admin/reviewDetailsDocumentSelectStage/ReviewDetailsDocumentSelectStage.tsx
@@ -55,6 +55,15 @@ const ReviewDetailsDocumentSelectStage = ({
),
);
};
+
+ const onError = (): void => {
+ navigate(
+ routeChildren.ADMIN_REVIEW_FILE_ERRORS.replaceAll(
+ ':reviewId',
+ `${reviewData?.id}.${reviewData?.version}`,
+ ),
+ );
+ };
if (!reviewData?.snomedCode) {
return ;
}
@@ -67,6 +76,7 @@ const ReviewDetailsDocumentSelectStage = ({
filesErrorRef={filesErrorRef}
documentConfig={getConfigForDocType(reviewData.snomedCode)}
onSuccessOverride={onSuccess}
+ onErrorOverride={onError}
backLinkOverride={(): void => {
navigate(-1);
}}
diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx
index 5b76b16f8..0e261866a 100644
--- a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx
+++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.test.tsx
@@ -343,6 +343,30 @@ describe('DocumentSelectStage', () => {
);
});
});
+
+ it('should call onErrorOverride when provided instead of navigating to default error route', async () => {
+ const mockOnErrorOverride = vi.fn();
+ const lgDocumentOne = buildLgFile(1);
+
+ vi.mocked(getDocument).mockImplementationOnce(() => {
+ throw new Error(PDF_PARSING_ERROR_TYPE.PASSWORD_MISSING);
+ });
+
+ renderApp(history, { onErrorOverride: mockOnErrorOverride });
+
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.drop(dropzone, {
+ dataTransfer: { files: [lgDocumentOne] },
+ });
+
+ await waitFor(() => {
+ expect(mockOnErrorOverride).toHaveBeenCalled();
+ });
+
+ expect(mockedUseNavigate).not.toHaveBeenCalledWith(
+ routeChildren.DOCUMENT_UPLOAD_FILE_ERRORS,
+ );
+ });
});
describe('Update Journey', () => {
@@ -741,6 +765,7 @@ describe('DocumentSelectStage', () => {
documentConfig?: DOCUMENT_TYPE_CONFIG;
isReview?: boolean;
initialDocuments?: Array;
+ onErrorOverride?: () => void;
};
const TestApp = ({
@@ -751,6 +776,7 @@ describe('DocumentSelectStage', () => {
documentConfig,
isReview,
initialDocuments,
+ onErrorOverride,
}: TestAppProps): JSX.Element => {
const [documents, setDocuments] = useState>(
initialDocuments ?? [],
@@ -769,6 +795,7 @@ describe('DocumentSelectStage', () => {
showSkiplink={showSkipLink}
removeAllFilesLinkOverride={removeAllFilesLinkOverride}
isReview={isReview}
+ onErrorOverride={onErrorOverride}
/>
);
};
diff --git a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx
index b79df0c56..bca233350 100644
--- a/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx
+++ b/app/src/components/blocks/_documentUpload/documentSelectStage/DocumentSelectStage.tsx
@@ -42,6 +42,7 @@ export type Props = {
filesErrorRef: RefObject;
documentConfig: DOCUMENT_TYPE_CONFIG;
onSuccessOverride?: () => void;
+ onErrorOverride?: () => void;
backLinkOverride?: () => void;
removeAllFilesLinkOverride?: string;
goToNextDocType?: () => void;
@@ -59,6 +60,7 @@ const DocumentSelectStage = ({
filesErrorRef,
documentConfig,
onSuccessOverride,
+ onErrorOverride,
backLinkOverride,
removeAllFilesLinkOverride,
goToNextDocType,
@@ -197,6 +199,10 @@ const DocumentSelectStage = ({
if (failedDocs.length > 0) {
filesErrorRef.current = true;
setDocuments(failedDocs);
+ if (onErrorOverride) {
+ onErrorOverride();
+ return;
+ }
navigate(routeChildren.DOCUMENT_UPLOAD_FILE_ERRORS);
return;
}
diff --git a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx
index 303cec71b..69daec7c7 100644
--- a/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx
+++ b/app/src/pages/adminRoutesPage/AdminRoutesPage.tsx
@@ -15,6 +15,7 @@ import ReviewsDetailsStage from '../../components/blocks/_admin/reviewsDetailsSt
import ReviewDetailsPatientSearchStage from '../../components/blocks/_admin/reviewDetailsPatientSearchStage/ReviewDetailsPatientSearchStage';
import { ReviewsPage } from '../../components/blocks/_admin/reviewsPage/ReviewsPage';
import PatientVerifyPage from '../../components/blocks/generic/patientVerifyPage/PatientVerifyPage';
+import DocumentSelectFileErrorsPage from '../../components/blocks/_documentUpload/documentSelectFileErrorsPage/DocumentSelectFileErrorsPage';
import useConfig from '../../helpers/hooks/useConfig';
import { getLastURLPath } from '../../helpers/utils/urlManipulations';
import { routeChildren, routes } from '../../types/generic/routes';
@@ -177,6 +178,10 @@ const AdminRoutesPage = (): JSX.Element => {
/>
}
/>
+ }
+ />
}
diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts
index aed587d44..cc118920d 100644
--- a/app/src/types/generic/routes.ts
+++ b/app/src/types/generic/routes.ts
@@ -83,6 +83,7 @@ export enum routeChildren {
ADMIN_REVIEW_REMOVE_ALL = '/admin/reviews/:reviewId/remove-all',
ADMIN_REVIEW_UPLOAD_FILE_ORDER = '/admin/reviews/:reviewId/upload-file-order',
ADMIN_REVIEW_UPLOAD = '/admin/reviews/:reviewId/upload',
+ ADMIN_REVIEW_FILE_ERRORS = '/admin/reviews/:reviewId/file-errors',
REVIEWS = 'reviews/*',
COOKIES_POLICY_UPDATED = '/cookies-policy/confirmation',