From 28b1b1973bab246f012ad92a14b73ddbf7f57b58 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Fri, 22 Aug 2025 16:54:52 +0500 Subject: [PATCH] feat: updated v2 captcha to v3 in post editor (#803) * feat: updated v2 captcha to v3 in post editor * feat: added google captcha v3 for comment * test: added test cases * test: added test case to update the post * test: updated test case for preview node * test: updated test case for comment error * test: removed mock file * fix: removed comments --------- Co-authored-by: sundasnoreen12 --- package-lock.json | 13 ++ package.json | 1 + .../discussions-home/DiscussionContent.jsx | 46 ++--- .../post-comments/PostCommentsView.test.jsx | 157 ++++++------------ .../comments/comment/CommentEditor.jsx | 76 +++------ src/discussions/post-comments/messages.js | 9 +- .../posts/post-editor/PostEditor.jsx | 78 +++------ .../posts/post-editor/PostEditor.test.jsx | 146 +++++++++------- src/discussions/posts/post-editor/messages.js | 9 +- .../mocksData/react-google-recaptcha.jsx | 42 ----- 10 files changed, 230 insertions(+), 347 deletions(-) delete mode 100644 src/discussions/posts/post-editor/mocksData/react-google-recaptcha.jsx diff --git a/package-lock.json b/package-lock.json index a016ef4c..b13f0282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-google-recaptcha": "^3.1.0", + "react-google-recaptcha-v3": "^1.11.0", "react-helmet": "6.1.0", "react-redux": "7.2.9", "react-router": "6.18.0", @@ -21912,6 +21913,18 @@ "react": ">=16.4.1" } }, + "node_modules/react-google-recaptcha-v3": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.11.0.tgz", + "integrity": "sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==", + "dependencies": { + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "react": "^16.3 || ^17.0 || ^18.0 || ^19.0", + "react-dom": "^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/react-helmet": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", diff --git a/package.json b/package.json index 0ba12e7c..90da23c8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-google-recaptcha": "^3.1.0", + "react-google-recaptcha-v3": "^1.11.0", "react-helmet": "6.1.0", "react-redux": "7.2.9", "react-router": "6.18.0", diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx index e7542595..e9107a35 100644 --- a/src/discussions/discussions-home/DiscussionContent.jsx +++ b/src/discussions/discussions-home/DiscussionContent.jsx @@ -1,38 +1,46 @@ import React, { lazy, Suspense } from 'react'; +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; import { useSelector } from 'react-redux'; import { Route, Routes } from 'react-router-dom'; import Spinner from '../../components/Spinner'; import { Routes as ROUTES } from '../../data/constants'; +import { selectCaptchaSettings } from '../data/selectors'; const PostEditor = lazy(() => import('../posts/post-editor/PostEditor')); const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView')); const DiscussionContent = () => { const postEditorVisible = useSelector((state) => state.threads.postEditorVisible); + const captchaSettings = useSelector(selectCaptchaSettings); return ( -
-
- )}> - - {postEditorVisible ? ( - } /> - ) : ( - <> - {ROUTES.POSTS.EDIT_POST.map(route => ( - } /> - ))} - {ROUTES.COMMENTS.PATH.map(route => ( - } /> - ))} - - )} - - + +
+
+ )}> + + {postEditorVisible ? ( + } /> + ) : ( + <> + {ROUTES.POSTS.EDIT_POST.map(route => ( + } /> + ))} + {ROUTES.COMMENTS.PATH.map(route => ( + } /> + ))} + + )} + + +
-
+ ); }; diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index 8009e592..2de52873 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -1,9 +1,8 @@ -import React, { useRef } from 'react'; - import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, Route, Routes, useLocation, @@ -27,12 +26,6 @@ import fetchCourseConfig from '../data/thunks'; import DiscussionContent from '../discussions-home/DiscussionContent'; import { getThreadsApiUrl } from '../posts/data/api'; import { fetchThread, fetchThreads } from '../posts/data/thunks'; -import MockReCAPTCHA, { - mockOnChange, - mockOnError, - mockOnExpired, - mockReset, -} from '../posts/post-editor/mocksData/react-google-recaptcha'; import fetchCourseTopics from '../topics/data/thunks'; import { getDiscussionTourUrl } from '../tours/data/api'; import selectTours from '../tours/data/selectors'; @@ -63,7 +56,11 @@ let testLocation; let container; let unmount; -jest.mock('react-google-recaptcha', () => MockReCAPTCHA); +jest.mock('react-google-recaptcha-v3', () => ({ + useGoogleReCaptcha: jest.fn(), + // eslint-disable-next-line react/prop-types + GoogleReCaptchaProvider: ({ children }) =>
{children}
, +})); async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) { axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, { @@ -310,6 +307,8 @@ describe('ThreadView', () => { it('should show and hide the editor', async () => { await setupCourseConfig(); + const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token')); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew }); await waitFor(() => renderComponent(discussionPostId)); const post = screen.getByTestId('post-thread-1'); @@ -348,13 +347,18 @@ describe('ThreadView', () => { it('should allow posting a comment with CAPTCHA', async () => { await setupCourseConfig(true, false, false); + const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token')); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); + await waitFor(() => renderComponent(discussionPostId)); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); + await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); }); + + await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); }); - await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); }); await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); await waitFor(() => { @@ -366,10 +370,43 @@ describe('ThreadView', () => { raw_body: 'New comment with CAPTCHA', thread_id: 'thread-1', }); - expect(mockOnChange).toHaveBeenCalled(); }); }); + it('should show captcha error if executeRecaptcha returns null token', async () => { + await setupCourseConfig(true, false, false); + + const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); + + await waitFor(() => renderComponent(discussionPostId)); + + const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); + const hoverCard = within(comment).getByTestId('hover-card-comment-1'); + await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); }); + await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); }); + await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); + + expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument(); + }); + + it('should show captcha error if executeRecaptcha throws', async () => { + await setupCourseConfig(true, false, false); + + const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed')); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); + + await waitFor(() => renderComponent(discussionPostId)); + + const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); + const hoverCard = within(comment).getByTestId('hover-card-comment-1'); + await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); }); + await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); }); + await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); + + expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument(); + }); + it('should allow posting a comment', async () => { await setupCourseConfig(); await waitFor(() => renderComponent(discussionPostId)); @@ -659,46 +696,6 @@ describe('ThreadView', () => { describe('for discussion thread', () => { const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); - it('renders the mocked ReCAPTCHA.', async () => { - await setupCourseConfig(true, false, false); - await waitFor(() => renderComponent(discussionPostId)); - await act(async () => { - fireEvent.click(screen.queryByText('Add comment')); - }); - expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument(); - }); - - it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => { - await setupCourseConfig(true, false, false); - await waitFor(() => renderComponent(discussionPostId)); - await act(async () => { - fireEvent.click(screen.queryByText('Add comment')); - }); - const solveButton = screen.getByText('Solve CAPTCHA'); - fireEvent.click(solveButton); - expect(mockOnChange).toHaveBeenCalled(); - }); - - it('successfully calls onExpired handler when CAPTCHA expires', async () => { - await setupCourseConfig(true, false, false); - await waitFor(() => renderComponent(discussionPostId)); - await act(async () => { - fireEvent.click(screen.queryByText('Add comment')); - }); - fireEvent.click(screen.getByText('Expire CAPTCHA')); - expect(mockOnExpired).toHaveBeenCalled(); - }); - - it('successfully calls onError handler when CAPTCHA errors', async () => { - await setupCourseConfig(true, false, false); - await waitFor(() => renderComponent(discussionPostId)); - await act(async () => { - fireEvent.click(screen.queryByText('Add comment')); - }); - fireEvent.click(screen.getByText('Error CAPTCHA')); - expect(mockOnError).toHaveBeenCalled(); - }); - it('shown post not found when post id does not belong to course', async () => { await waitFor(() => renderComponent('unloaded-id')); expect(await screen.findByText('Thread not found', { exact: true })) @@ -1161,61 +1158,3 @@ describe('ThreadView', () => { expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); }); }); - -describe('MockReCAPTCHA', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('uses defaultProps when props are not provided', () => { - render(); - - expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument(); - - fireEvent.click(screen.getByText('Solve CAPTCHA')); - fireEvent.click(screen.getByText('Expire CAPTCHA')); - fireEvent.click(screen.getByText('Error CAPTCHA')); - - expect(mockOnChange).toHaveBeenCalled(); - expect(mockOnExpired).toHaveBeenCalled(); - expect(mockOnError).toHaveBeenCalled(); - }); - - it('triggers all callbacks and exposes reset via ref', () => { - const onChange = jest.fn(); - const onExpired = jest.fn(); - const onError = jest.fn(); - - const Wrapper = () => { - const recaptchaRef = useRef(null); - return ( -
- - -
- ); - }; - - const { getByText, getByTestId } = render(); - - fireEvent.click(getByText('Solve CAPTCHA')); - fireEvent.click(getByText('Expire CAPTCHA')); - fireEvent.click(getByText('Error CAPTCHA')); - - fireEvent.click(getByTestId('reset-btn')); - - expect(mockOnChange).toHaveBeenCalled(); - expect(mockOnExpired).toHaveBeenCalled(); - expect(mockOnError).toHaveBeenCalled(); - - expect(onChange).toHaveBeenCalledWith('mock-token'); - expect(onExpired).toHaveBeenCalled(); - expect(onError).toHaveBeenCalled(); - expect(mockReset).toHaveBeenCalled(); - }); -}); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index 94d045bc..c8fd84dd 100644 --- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx +++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { Button, Form, StatefulButton } from '@openedx/paragon'; import { Formik } from 'formik'; -import ReCAPTCHA from 'react-google-recaptcha'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; import { useSelector } from 'react-redux'; import * as Yup from 'yup'; @@ -49,7 +49,7 @@ const CommentEditor = ({ const [isSubmitting, setIsSubmitting] = useState(false); const editorRef = useRef(null); const formRef = useRef(null); - const recaptchaRef = useRef(null); + const { executeRecaptcha } = useGoogleReCaptcha(); const { authenticatedUser } = useContext(AppContext); const { enableInContextSidebar } = useContext(DiscussionContext); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); @@ -63,13 +63,10 @@ const CommentEditor = ({ const isUserLearner = useSelector(selectIsUserLearner); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const postStatus = useSelector(selectPostStatus); + const [captchaError, setCaptchaError] = useState(''); const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner; - const captchaValidation = { - recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)), - }; - const canDisplayEditReason = (edit && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) && author !== authenticatedUser.username @@ -82,15 +79,12 @@ const CommentEditor = ({ const validationSchema = Yup.object().shape({ comment: Yup.string() .required(), - ...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }), ...editReasonCodeValidation, - ...(shouldRequireCaptcha ? captchaValidation : {}), }); const initialValues = { comment: editorContent, editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined), - recaptchaToken: '', }; useEffect(() => { @@ -99,20 +93,8 @@ const CommentEditor = ({ } }, [contentCreationRateLimited, id]); - const handleCaptchaChange = useCallback((token, setFieldValue) => { - setFieldValue('recaptchaToken', token || ''); - }, []); - - const handleCaptchaExpired = useCallback((setFieldValue) => { - setFieldValue('recaptchaToken', ''); - }, []); - const handleCloseEditor = useCallback((resetForm) => { resetForm({ values: initialValues }); - // Reset CAPTCHA when hiding editor - if (recaptchaRef.current) { - recaptchaRef.current.reset(); - } }, [initialValues]); const deleteEditorContent = useCallback(async () => { @@ -125,13 +107,14 @@ const CommentEditor = ({ }, [parentId, id, threadId, setDraftComments, setDraftResponses]); useEffect(() => { - if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting) { + if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting && !captchaError) { onCloseEditor(); } }, [postStatus, isSubmitting]); const saveUpdatedComment = useCallback(async (values, { resetForm }) => { setIsSubmitting(true); + let recaptchaToken; if (id) { const payload = { ...values, @@ -139,7 +122,20 @@ const CommentEditor = ({ }; await dispatch(editComment(id, payload)); } else { - await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : '')); + if (shouldRequireCaptcha && executeRecaptcha) { + try { + recaptchaToken = await executeRecaptcha('submit_post'); + if (!recaptchaToken) { + setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel)); + return; + } + } catch (error) { + setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel)); + return; + } + setCaptchaError(''); + } + await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? recaptchaToken : '')); } /* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */ if (editorRef.current) { @@ -147,7 +143,7 @@ const CommentEditor = ({ } handleCloseEditor(resetForm); deleteEditorContent(); - }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]); + }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha, executeRecaptcha]); // The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to // the current comment id, or the current comment parent or the curren thread. const editorId = `comment-editor-${id || parentId || threadId}`; @@ -194,7 +190,6 @@ const CommentEditor = ({ handleBlur, handleChange, resetForm, - setFieldValue, }) => (
{canDisplayEditReason && ( @@ -250,32 +245,11 @@ const CommentEditor = ({ )} - {/* CAPTCHA Section - Only show for new posts from non-staff users */} - { shouldRequireCaptcha && captchaSettings.siteKey && ( -
- - - {intl.formatMessage(messages.verifyHumanLabel)} - -
- handleCaptchaChange(token, setFieldValue)} - onExpired={() => handleCaptchaExpired(setFieldValue)} - onError={() => handleCaptchaExpired(setFieldValue)} - /> -
- -
-
- ) } - + { shouldRequireCaptcha && captchaSettings.siteKey && captchaError && ( +
+ {captchaError} +
+ )}
- {/* CAPTCHA Section - Only show for new posts for non-staff users */} - {shouldRequireCaptcha && captchaSettings.siteKey && ( -
- - - {intl.formatMessage(messages.verifyHumanLabel)} - -
- handleCaptchaChange(token, setFieldValue)} - onExpired={() => handleCaptchaExpired(setFieldValue)} - onError={() => handleCaptchaExpired(setFieldValue)} - /> -
- -
+ { shouldRequireCaptcha && captchaSettings.siteKey && captchaError && ( +
+ {captchaError}
)}
diff --git a/src/discussions/posts/post-editor/PostEditor.test.jsx b/src/discussions/posts/post-editor/PostEditor.test.jsx index 2781b32b..e9c0c504 100644 --- a/src/discussions/posts/post-editor/PostEditor.test.jsx +++ b/src/discussions/posts/post-editor/PostEditor.test.jsx @@ -6,6 +6,7 @@ import { import userEvent from '@testing-library/user-event'; import MockAdapter from 'axios-mock-adapter'; import { act } from 'react-dom/test-utils'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Factory } from 'rosie'; @@ -25,10 +26,8 @@ import { getCourseConfigApiUrl } from '../../data/api'; import fetchCourseConfig from '../../data/thunks'; import fetchCourseTopics from '../../topics/data/thunks'; import { getThreadsApiUrl } from '../data/api'; +import * as selectors from '../data/selectors'; import { fetchThread } from '../data/thunks'; -import MockReCAPTCHA, { - mockOnChange, mockOnError, mockOnExpired, -} from './mocksData/react-google-recaptcha'; import PostEditor from './PostEditor'; import '../../cohorts/data/__factories__'; @@ -45,7 +44,11 @@ let store; let axiosMock; let container; -jest.mock('react-google-recaptcha', () => MockReCAPTCHA); +jest.mock('react-google-recaptcha-v3', () => ({ + useGoogleReCaptcha: jest.fn(), + // eslint-disable-next-line react/prop-types + GoogleReCaptchaProvider: ({ children }) =>
{children}
, +})); async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) { const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST]; @@ -112,6 +115,8 @@ describe('PostEditor submit Form', () => { }); test('successfully submits a new post with CAPTCHA', async () => { + const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token')); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); const newThread = Factory.build('thread', { id: 'new-thread-1' }); axiosMock.onPost(threadsApiUrl).reply(200, newThread); @@ -123,11 +128,9 @@ describe('PostEditor submit Form', () => { }); const postTitle = await screen.findByTestId('post-title-input'); const tinymceEditor = await screen.findByTestId('tinymce-editor'); - const solveButton = screen.getByText('Solve CAPTCHA'); fireEvent.change(postTitle, { target: { value: 'Test Post Title' } }); fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } }); - fireEvent.click(solveButton); }); await act(async () => { @@ -152,7 +155,56 @@ describe('PostEditor submit Form', () => { }); }); - test('fails to submit a new post with CAPTCHA if token is missing', async () => { + test('successfully updated a post', async () => { + const mockThread = { + courseId: 'course-v1:edX+DemoX+Demo_Course', + topicId: 'ncw-topic-1', + type: 'discussion', + title: 'Test Post Title', + rawBody: 'Test Post Content', + following: true, + anonymous: false, + anonymousToPeers: false, + enableInContextSidebar: false, + notifyAllLearners: false, + captchaToken: 'mock-token', + }; + jest + .spyOn(selectors, 'selectThread') + .mockImplementation(() => jest.fn(() => mockThread)); + + const newThread = Factory.build('thread', { id: 'new-thread-1' }); + axiosMock.onPatch(threadsApiUrl).reply(200, newThread); + + await renderComponent(true, `/${courseId}/posts/post1/edit`); + + await act(async () => { + fireEvent.change(screen.getByTestId('topic-select'), { + target: { value: 'ncw-topic-1' }, + }); + const postTitle = await screen.findByTestId('post-title-input'); + const tinymceEditor = await screen.findByTestId('tinymce-editor'); + + fireEvent.change(postTitle, { target: { value: 'Test Post Title' } }); + fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } }); + }); + + await act(async () => { + fireEvent.click(container.querySelector('[data-testid="show-preview-button"]')); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /submit/i })); + }); + + await act(async () => { + expect(axiosMock.history.patch).toHaveLength(1); + }); + }); + + test('successfully show captcha error if executeRecaptcha returns null token', async () => { + const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); const newThread = Factory.build('thread', { id: 'new-thread-1' }); axiosMock.onPost(threadsApiUrl).reply(200, newThread); @@ -173,9 +225,33 @@ describe('PostEditor submit Form', () => { fireEvent.click(screen.getByRole('button', { name: /submit/i })); }); - await waitFor(() => { - expect(screen.getByText('Please complete the CAPTCHA verification')).toBeInTheDocument(); + expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument(); + }); + + test('successfully show captcha error if executeRecaptcha throws', async () => { + const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed')); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha }); + const newThread = Factory.build('thread', { id: 'new-thread-1' }); + axiosMock.onPost(threadsApiUrl).reply(200, newThread); + + await renderComponent(); + + await act(async () => { + fireEvent.change(screen.getByTestId('topic-select'), { + target: { value: 'ncw-topic-1' }, + }); + const postTitle = await screen.findByTestId('post-title-input'); + const tinymceEditor = await screen.findByTestId('tinymce-editor'); + + fireEvent.change(postTitle, { target: { value: 'Test Post Title' } }); + fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } }); }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /submit/i })); + }); + + expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument(); }); }); @@ -339,58 +415,6 @@ describe('PostEditor', () => { await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState); } - test('renders the mocked ReCAPTCHA.', async () => { - await setupData({ - captchaSettings: { - enabled: true, - siteKey: 'test-key', - }, - hasModerationPrivileges: false, - }); - await renderComponent(); - expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument(); - }); - - test('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => { - await setupData({ - captchaSettings: { - enabled: true, - siteKey: 'test-key', - }, - hasModerationPrivileges: false, - }); - await renderComponent(); - const solveButton = screen.getByText('Solve CAPTCHA'); - fireEvent.click(solveButton); - expect(mockOnChange).toHaveBeenCalled(); - }); - - test('successfully calls onExpired handler when CAPTCHA expires', async () => { - await setupData({ - captchaSettings: { - enabled: true, - siteKey: 'test-key', - }, - hasModerationPrivileges: false, - }); - await renderComponent(); - fireEvent.click(screen.getByText('Expire CAPTCHA')); - expect(mockOnExpired).toHaveBeenCalled(); - }); - - test('successfully calls onError handler when CAPTCHA errors', async () => { - await setupData({ - captchaSettings: { - enabled: true, - siteKey: 'test-key', - }, - hasModerationPrivileges: false, - }); - await renderComponent(); - fireEvent.click(screen.getByText('Error CAPTCHA')); - expect(mockOnError).toHaveBeenCalled(); - }); - test('test privileged user', async () => { await setupData(); await renderComponent(); diff --git a/src/discussions/posts/post-editor/messages.js b/src/discussions/posts/post-editor/messages.js index d93c172c..402bb32e 100644 --- a/src/discussions/posts/post-editor/messages.js +++ b/src/discussions/posts/post-editor/messages.js @@ -177,13 +177,8 @@ const messages = defineMessages({ }, captchaVerificationLabel: { id: 'discussions.captcha.verification.label', - defaultMessage: 'Please complete the CAPTCHA verification', - description: 'Please complete the CAPTCHA to continue.', - }, - verifyHumanLabel: { - id: 'discussions.verify.human.label', - defaultMessage: 'Verify you are human', - description: 'Verify you are human description.', + defaultMessage: 'CAPTCHA verification failed.', + description: 'CAPTCHA verification failed', }, }); diff --git a/src/discussions/posts/post-editor/mocksData/react-google-recaptcha.jsx b/src/discussions/posts/post-editor/mocksData/react-google-recaptcha.jsx deleted file mode 100644 index 588d902f..00000000 --- a/src/discussions/posts/post-editor/mocksData/react-google-recaptcha.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -// Define mock functions -export const mockOnChange = jest.fn(); -export const mockOnExpired = jest.fn(); -export const mockOnError = jest.fn(); -export const mockReset = jest.fn(); - -const MockReCAPTCHA = React.forwardRef((props, ref) => { - const { onChange, onExpired, onError } = props; - React.useImperativeHandle(ref, () => ({ - reset: mockReset, - })); - - return ( -
- - - -
- ); -}); - -MockReCAPTCHA.propTypes = { - onChange: PropTypes.func, - onExpired: PropTypes.func, - onError: PropTypes.func, -}; -MockReCAPTCHA.defaultProps = { - onChange: () => {}, - onExpired: () => {}, - onError: () => {}, -}; - -export default MockReCAPTCHA;