Skip to content

Commit 2241575

Browse files
AhtishamShahidsundasnoreen12awais-ansari
authored
feat: added captcha to discussion post creation (#785)
* feat: added captcha to discussion post creation * feat: added captcha for comment and response * fix: removed learner check * test: fixed test cases * fix: removed comment and added check for empty sitekey * fix: fixed translation issue * test: added test cases for recaptcha * test: should allow posting a comment with CAPTCHA * test: added submit post test cases * test: test edge cases for api * test: added test cases for react-google-recaptcha * test: added test case for default values for captcha * fix: removed unused catch --------- Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com> Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
1 parent 8618e8c commit 2241575

File tree

16 files changed

+614
-19
lines changed

16 files changed

+614
-19
lines changed

package-lock.json

Lines changed: 25 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
@@ -47,6 +47,7 @@
4747
"raw-loader": "4.0.2",
4848
"react": "18.3.1",
4949
"react-dom": "18.3.1",
50+
"react-google-recaptcha": "^3.1.0",
5051
"react-helmet": "6.1.0",
5152
"react-redux": "7.2.9",
5253
"react-router": "6.18.0",

src/discussions/data/selectors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
3333

3434
export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled;
3535

36+
export const selectCaptchaSettings = state => state.config.captchaSettings;
37+
3638
export const selectModerationSettings = state => ({
3739
postCloseReasons: state.config.postCloseReasons,
3840
editReasons: state.config.editReasons,

src/discussions/data/slices.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const configSlice = createSlice({
2222
dividedInlineDiscussions: [],
2323
dividedCourseWideDiscussions: [],
2424
},
25+
captchaSettings: {
26+
enabled: false,
27+
siteKey: '',
28+
},
2529
editReasons: [],
2630
postCloseReasons: [],
2731
enableInContext: false,

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

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import React, { useRef } from 'react';
2+
13
import {
24
act, fireEvent, render, screen, waitFor, within,
35
} from '@testing-library/react';
@@ -23,6 +25,12 @@ import fetchCourseConfig from '../data/thunks';
2325
import DiscussionContent from '../discussions-home/DiscussionContent';
2426
import { getThreadsApiUrl } from '../posts/data/api';
2527
import { fetchThread, fetchThreads } from '../posts/data/thunks';
28+
import MockReCAPTCHA, {
29+
mockOnChange,
30+
mockOnError,
31+
mockOnExpired,
32+
mockReset,
33+
} from '../posts/post-editor/mocksData/react-google-recaptcha';
2634
import fetchCourseTopics from '../topics/data/thunks';
2735
import { getDiscussionTourUrl } from '../tours/data/api';
2836
import selectTours from '../tours/data/selectors';
@@ -51,6 +59,8 @@ let testLocation;
5159
let container;
5260
let unmount;
5361

62+
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
63+
5464
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
5565
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
5666
threadId,
@@ -215,7 +225,13 @@ describe('ThreadView', () => {
215225
endorsed: false,
216226
})];
217227
});
218-
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
228+
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
229+
isPostingEnabled: true,
230+
captchaSettings: {
231+
enabled: true,
232+
siteKey: 'test-key',
233+
},
234+
});
219235
window.HTMLElement.prototype.scrollIntoView = jest.fn();
220236

221237
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
@@ -292,6 +308,29 @@ describe('ThreadView', () => {
292308
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
293309
});
294310

311+
it('should allow posting a comment with CAPTCHA', async () => {
312+
await waitFor(() => renderComponent(discussionPostId));
313+
314+
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
315+
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
316+
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
317+
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
318+
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
319+
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
320+
321+
await waitFor(() => {
322+
expect(axiosMock.history.post).toHaveLength(1);
323+
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
324+
captcha_token: 'm',
325+
enable_in_context_sidebar: false,
326+
parent_id: 'comment-1',
327+
raw_body: 'New comment with CAPTCHA',
328+
thread_id: 'thread-1',
329+
});
330+
expect(mockOnChange).toHaveBeenCalled();
331+
});
332+
});
333+
295334
it('should allow posting a comment', async () => {
296335
await waitFor(() => renderComponent(discussionPostId));
297336

@@ -302,7 +341,6 @@ describe('ThreadView', () => {
302341
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
303342
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
304343

305-
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
306344
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
307345
});
308346

@@ -323,7 +361,6 @@ describe('ThreadView', () => {
323361
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
324362
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
325363

326-
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
327364
await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument());
328365
});
329366

@@ -581,6 +618,42 @@ describe('ThreadView', () => {
581618
describe('for discussion thread', () => {
582619
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
583620

621+
it('renders the mocked ReCAPTCHA.', async () => {
622+
await waitFor(() => renderComponent(discussionPostId));
623+
await act(async () => {
624+
fireEvent.click(screen.queryByText('Add comment'));
625+
});
626+
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
627+
});
628+
629+
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
630+
await waitFor(() => renderComponent(discussionPostId));
631+
await act(async () => {
632+
fireEvent.click(screen.queryByText('Add comment'));
633+
});
634+
const solveButton = screen.getByText('Solve CAPTCHA');
635+
fireEvent.click(solveButton);
636+
expect(mockOnChange).toHaveBeenCalled();
637+
});
638+
639+
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
640+
await waitFor(() => renderComponent(discussionPostId));
641+
await act(async () => {
642+
fireEvent.click(screen.queryByText('Add comment'));
643+
});
644+
fireEvent.click(screen.getByText('Expire CAPTCHA'));
645+
expect(mockOnExpired).toHaveBeenCalled();
646+
});
647+
648+
it('successfully calls onError handler when CAPTCHA errors', async () => {
649+
await waitFor(() => renderComponent(discussionPostId));
650+
await act(async () => {
651+
fireEvent.click(screen.queryByText('Add comment'));
652+
});
653+
fireEvent.click(screen.getByText('Error CAPTCHA'));
654+
expect(mockOnError).toHaveBeenCalled();
655+
});
656+
584657
it('shown post not found when post id does not belong to course', async () => {
585658
await waitFor(() => renderComponent('unloaded-id'));
586659
expect(await screen.findByText('Thread not found', { exact: true }))
@@ -749,7 +822,7 @@ describe('ThreadView', () => {
749822
fireEvent.click(screen.queryAllByText('Add comment')[0]);
750823
});
751824

752-
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
825+
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
753826
});
754827

755828
it('successfully added response in the draft.', async () => {
@@ -793,7 +866,7 @@ describe('ThreadView', () => {
793866
fireEvent.click(screen.queryByText('Add response'));
794867
});
795868

796-
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
869+
expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
797870
});
798871

799872
it('successfully maintain response for the specific post in the draft.', async () => {
@@ -975,3 +1048,61 @@ describe('ThreadView', () => {
9751048
});
9761049
});
9771050
});
1051+
1052+
describe('MockReCAPTCHA', () => {
1053+
beforeEach(() => {
1054+
jest.clearAllMocks();
1055+
});
1056+
1057+
test('uses defaultProps when props are not provided', () => {
1058+
render(<MockReCAPTCHA />);
1059+
1060+
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
1061+
1062+
fireEvent.click(screen.getByText('Solve CAPTCHA'));
1063+
fireEvent.click(screen.getByText('Expire CAPTCHA'));
1064+
fireEvent.click(screen.getByText('Error CAPTCHA'));
1065+
1066+
expect(mockOnChange).toHaveBeenCalled();
1067+
expect(mockOnExpired).toHaveBeenCalled();
1068+
expect(mockOnError).toHaveBeenCalled();
1069+
});
1070+
1071+
it('triggers all callbacks and exposes reset via ref', () => {
1072+
const onChange = jest.fn();
1073+
const onExpired = jest.fn();
1074+
const onError = jest.fn();
1075+
1076+
const Wrapper = () => {
1077+
const recaptchaRef = useRef(null);
1078+
return (
1079+
<div>
1080+
<MockReCAPTCHA
1081+
ref={recaptchaRef}
1082+
onChange={onChange}
1083+
onExpired={onExpired}
1084+
onError={onError}
1085+
/>
1086+
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
1087+
</div>
1088+
);
1089+
};
1090+
1091+
const { getByText, getByTestId } = render(<Wrapper />);
1092+
1093+
fireEvent.click(getByText('Solve CAPTCHA'));
1094+
fireEvent.click(getByText('Expire CAPTCHA'));
1095+
fireEvent.click(getByText('Error CAPTCHA'));
1096+
1097+
fireEvent.click(getByTestId('reset-btn'));
1098+
1099+
expect(mockOnChange).toHaveBeenCalled();
1100+
expect(mockOnExpired).toHaveBeenCalled();
1101+
expect(mockOnError).toHaveBeenCalled();
1102+
1103+
expect(onChange).toHaveBeenCalledWith('mock-token');
1104+
expect(onExpired).toHaveBeenCalled();
1105+
expect(onError).toHaveBeenCalled();
1106+
expect(mockReset).toHaveBeenCalled();
1107+
});
1108+
});

0 commit comments

Comments
 (0)