Skip to content

Commit 28b1b19

Browse files
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 <[email protected]>
1 parent 76fabbf commit 28b1b19

File tree

10 files changed

+230
-347
lines changed

10 files changed

+230
-347
lines changed

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"react": "18.3.1",
4949
"react-dom": "18.3.1",
5050
"react-google-recaptcha": "^3.1.0",
51+
"react-google-recaptcha-v3": "^1.11.0",
5152
"react-helmet": "6.1.0",
5253
"react-redux": "7.2.9",
5354
"react-router": "6.18.0",

src/discussions/discussions-home/DiscussionContent.jsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,46 @@
11
import React, { lazy, Suspense } from 'react';
22

3+
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
34
import { useSelector } from 'react-redux';
45
import { Route, Routes } from 'react-router-dom';
56

67
import Spinner from '../../components/Spinner';
78
import { Routes as ROUTES } from '../../data/constants';
9+
import { selectCaptchaSettings } from '../data/selectors';
810

911
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
1012
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
1113

1214
const DiscussionContent = () => {
1315
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
16+
const captchaSettings = useSelector(selectCaptchaSettings);
1417

1518
return (
16-
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center overflow-auto">
17-
<div className="d-flex flex-column w-100">
18-
<Suspense fallback={(<Spinner />)}>
19-
<Routes>
20-
{postEditorVisible ? (
21-
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
22-
) : (
23-
<>
24-
{ROUTES.POSTS.EDIT_POST.map(route => (
25-
<Route key={route} path={route} element={<PostEditor editExisting />} />
26-
))}
27-
{ROUTES.COMMENTS.PATH.map(route => (
28-
<Route key={route} path={route} element={<PostCommentsView />} />
29-
))}
30-
</>
31-
)}
32-
</Routes>
33-
</Suspense>
19+
<GoogleReCaptchaProvider
20+
reCaptchaKey={captchaSettings.siteKey}
21+
useEnterprise
22+
>
23+
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center overflow-auto">
24+
<div className="d-flex flex-column w-100">
25+
<Suspense fallback={(<Spinner />)}>
26+
<Routes>
27+
{postEditorVisible ? (
28+
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
29+
) : (
30+
<>
31+
{ROUTES.POSTS.EDIT_POST.map(route => (
32+
<Route key={route} path={route} element={<PostEditor editExisting />} />
33+
))}
34+
{ROUTES.COMMENTS.PATH.map(route => (
35+
<Route key={route} path={route} element={<PostCommentsView />} />
36+
))}
37+
</>
38+
)}
39+
</Routes>
40+
</Suspense>
41+
</div>
3442
</div>
35-
</div>
43+
</GoogleReCaptchaProvider>
3644
);
3745
};
3846

src/discussions/post-comments/PostCommentsView.test.jsx

Lines changed: 48 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import React, { useRef } from 'react';
2-
31
import {
42
act, fireEvent, render, screen, waitFor, within,
53
} from '@testing-library/react';
64
import MockAdapter from 'axios-mock-adapter';
5+
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
76
import { IntlProvider } from 'react-intl';
87
import {
98
MemoryRouter, Route, Routes, useLocation,
@@ -27,12 +26,6 @@ import fetchCourseConfig from '../data/thunks';
2726
import DiscussionContent from '../discussions-home/DiscussionContent';
2827
import { getThreadsApiUrl } from '../posts/data/api';
2928
import { fetchThread, fetchThreads } from '../posts/data/thunks';
30-
import MockReCAPTCHA, {
31-
mockOnChange,
32-
mockOnError,
33-
mockOnExpired,
34-
mockReset,
35-
} from '../posts/post-editor/mocksData/react-google-recaptcha';
3629
import fetchCourseTopics from '../topics/data/thunks';
3730
import { getDiscussionTourUrl } from '../tours/data/api';
3831
import selectTours from '../tours/data/selectors';
@@ -63,7 +56,11 @@ let testLocation;
6356
let container;
6457
let unmount;
6558

66-
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
59+
jest.mock('react-google-recaptcha-v3', () => ({
60+
useGoogleReCaptcha: jest.fn(),
61+
// eslint-disable-next-line react/prop-types
62+
GoogleReCaptchaProvider: ({ children }) => <div>{children}</div>,
63+
}));
6764

6865
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
6966
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -310,6 +307,8 @@ describe('ThreadView', () => {
310307

311308
it('should show and hide the editor', async () => {
312309
await setupCourseConfig();
310+
const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
311+
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
313312
await waitFor(() => renderComponent(discussionPostId));
314313

315314
const post = screen.getByTestId('post-thread-1');
@@ -348,13 +347,18 @@ describe('ThreadView', () => {
348347

349348
it('should allow posting a comment with CAPTCHA', async () => {
350349
await setupCourseConfig(true, false, false);
350+
const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token'));
351+
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
352+
351353
await waitFor(() => renderComponent(discussionPostId));
352354

353355
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
354356
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
357+
355358
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
359+
360+
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
356361
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
357-
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
358362
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
359363

360364
await waitFor(() => {
@@ -366,10 +370,43 @@ describe('ThreadView', () => {
366370
raw_body: 'New comment with CAPTCHA',
367371
thread_id: 'thread-1',
368372
});
369-
expect(mockOnChange).toHaveBeenCalled();
370373
});
371374
});
372375

376+
it('should show captcha error if executeRecaptcha returns null token', async () => {
377+
await setupCourseConfig(true, false, false);
378+
379+
const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null);
380+
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
381+
382+
await waitFor(() => renderComponent(discussionPostId));
383+
384+
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
385+
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
386+
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
387+
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
388+
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
389+
390+
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
391+
});
392+
393+
it('should show captcha error if executeRecaptcha throws', async () => {
394+
await setupCourseConfig(true, false, false);
395+
396+
const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed'));
397+
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
398+
399+
await waitFor(() => renderComponent(discussionPostId));
400+
401+
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
402+
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
403+
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
404+
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
405+
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
406+
407+
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
408+
});
409+
373410
it('should allow posting a comment', async () => {
374411
await setupCourseConfig();
375412
await waitFor(() => renderComponent(discussionPostId));
@@ -659,46 +696,6 @@ describe('ThreadView', () => {
659696
describe('for discussion thread', () => {
660697
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
661698

662-
it('renders the mocked ReCAPTCHA.', async () => {
663-
await setupCourseConfig(true, false, false);
664-
await waitFor(() => renderComponent(discussionPostId));
665-
await act(async () => {
666-
fireEvent.click(screen.queryByText('Add comment'));
667-
});
668-
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
669-
});
670-
671-
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
672-
await setupCourseConfig(true, false, false);
673-
await waitFor(() => renderComponent(discussionPostId));
674-
await act(async () => {
675-
fireEvent.click(screen.queryByText('Add comment'));
676-
});
677-
const solveButton = screen.getByText('Solve CAPTCHA');
678-
fireEvent.click(solveButton);
679-
expect(mockOnChange).toHaveBeenCalled();
680-
});
681-
682-
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
683-
await setupCourseConfig(true, false, false);
684-
await waitFor(() => renderComponent(discussionPostId));
685-
await act(async () => {
686-
fireEvent.click(screen.queryByText('Add comment'));
687-
});
688-
fireEvent.click(screen.getByText('Expire CAPTCHA'));
689-
expect(mockOnExpired).toHaveBeenCalled();
690-
});
691-
692-
it('successfully calls onError handler when CAPTCHA errors', async () => {
693-
await setupCourseConfig(true, false, false);
694-
await waitFor(() => renderComponent(discussionPostId));
695-
await act(async () => {
696-
fireEvent.click(screen.queryByText('Add comment'));
697-
});
698-
fireEvent.click(screen.getByText('Error CAPTCHA'));
699-
expect(mockOnError).toHaveBeenCalled();
700-
});
701-
702699
it('shown post not found when post id does not belong to course', async () => {
703700
await waitFor(() => renderComponent('unloaded-id'));
704701
expect(await screen.findByText('Thread not found', { exact: true }))
@@ -1161,61 +1158,3 @@ describe('ThreadView', () => {
11611158
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
11621159
});
11631160
});
1164-
1165-
describe('MockReCAPTCHA', () => {
1166-
beforeEach(() => {
1167-
jest.clearAllMocks();
1168-
});
1169-
1170-
test('uses defaultProps when props are not provided', () => {
1171-
render(<MockReCAPTCHA />);
1172-
1173-
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
1174-
1175-
fireEvent.click(screen.getByText('Solve CAPTCHA'));
1176-
fireEvent.click(screen.getByText('Expire CAPTCHA'));
1177-
fireEvent.click(screen.getByText('Error CAPTCHA'));
1178-
1179-
expect(mockOnChange).toHaveBeenCalled();
1180-
expect(mockOnExpired).toHaveBeenCalled();
1181-
expect(mockOnError).toHaveBeenCalled();
1182-
});
1183-
1184-
it('triggers all callbacks and exposes reset via ref', () => {
1185-
const onChange = jest.fn();
1186-
const onExpired = jest.fn();
1187-
const onError = jest.fn();
1188-
1189-
const Wrapper = () => {
1190-
const recaptchaRef = useRef(null);
1191-
return (
1192-
<div>
1193-
<MockReCAPTCHA
1194-
ref={recaptchaRef}
1195-
onChange={onChange}
1196-
onExpired={onExpired}
1197-
onError={onError}
1198-
/>
1199-
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
1200-
</div>
1201-
);
1202-
};
1203-
1204-
const { getByText, getByTestId } = render(<Wrapper />);
1205-
1206-
fireEvent.click(getByText('Solve CAPTCHA'));
1207-
fireEvent.click(getByText('Expire CAPTCHA'));
1208-
fireEvent.click(getByText('Error CAPTCHA'));
1209-
1210-
fireEvent.click(getByTestId('reset-btn'));
1211-
1212-
expect(mockOnChange).toHaveBeenCalled();
1213-
expect(mockOnExpired).toHaveBeenCalled();
1214-
expect(mockOnError).toHaveBeenCalled();
1215-
1216-
expect(onChange).toHaveBeenCalledWith('mock-token');
1217-
expect(onExpired).toHaveBeenCalled();
1218-
expect(onError).toHaveBeenCalled();
1219-
expect(mockReset).toHaveBeenCalled();
1220-
});
1221-
});

0 commit comments

Comments
 (0)