diff --git a/package-lock.json b/package-lock.json
index 6bfd2d19..a016ef4c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"raw-loader": "4.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
+ "react-google-recaptcha": "^3.1.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
@@ -21630,6 +21631,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-async-script": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz",
+ "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.0",
+ "prop-types": "^15.5.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.4.1"
+ }
+ },
"node_modules/react-bootstrap": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.8.tgz",
@@ -21887,6 +21900,18 @@
}
}
},
+ "node_modules/react-google-recaptcha": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz",
+ "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==",
+ "dependencies": {
+ "prop-types": "^15.5.0",
+ "react-async-script": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.4.1"
+ }
+ },
"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 30439134..0ba12e7c 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"raw-loader": "4.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
+ "react-google-recaptcha": "^3.1.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js
index 5e0474d5..b82201e0 100644
--- a/src/discussions/data/selectors.js
+++ b/src/discussions/data/selectors.js
@@ -33,6 +33,8 @@ export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled;
+export const selectCaptchaSettings = state => state.config.captchaSettings;
+
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js
index 071fc02e..f895c3f2 100644
--- a/src/discussions/data/slices.js
+++ b/src/discussions/data/slices.js
@@ -22,6 +22,10 @@ const configSlice = createSlice({
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
+ captchaSettings: {
+ enabled: false,
+ siteKey: '',
+ },
editReasons: [],
postCloseReasons: [],
enableInContext: false,
diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx
index 5c31c15c..e1fa23e8 100644
--- a/src/discussions/post-comments/PostCommentsView.test.jsx
+++ b/src/discussions/post-comments/PostCommentsView.test.jsx
@@ -1,3 +1,5 @@
+import React, { useRef } from 'react';
+
import {
act, fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
@@ -23,6 +25,12 @@ 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';
@@ -51,6 +59,8 @@ let testLocation;
let container;
let unmount;
+jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
+
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId,
@@ -215,7 +225,13 @@ describe('ThreadView', () => {
endorsed: false,
})];
});
- axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
+ axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
+ isPostingEnabled: true,
+ captchaSettings: {
+ enabled: true,
+ siteKey: 'test-key',
+ },
+ });
window.HTMLElement.prototype.scrollIntoView = jest.fn();
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
@@ -292,6 +308,29 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
});
+ it('should allow posting a comment with CAPTCHA', async () => {
+ 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('Solve CAPTCHA')); });
+ await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
+
+ await waitFor(() => {
+ expect(axiosMock.history.post).toHaveLength(1);
+ expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
+ captcha_token: 'm',
+ enable_in_context_sidebar: false,
+ parent_id: 'comment-1',
+ raw_body: 'New comment with CAPTCHA',
+ thread_id: 'thread-1',
+ });
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+ });
+
it('should allow posting a comment', async () => {
await waitFor(() => renderComponent(discussionPostId));
@@ -302,7 +341,6 @@ describe('ThreadView', () => {
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
- expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
});
@@ -323,7 +361,6 @@ describe('ThreadView', () => {
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
- expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument());
});
@@ -581,6 +618,42 @@ describe('ThreadView', () => {
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
+ it('renders the mocked ReCAPTCHA.', async () => {
+ 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 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 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 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 }))
@@ -749,7 +822,7 @@ describe('ThreadView', () => {
fireEvent.click(screen.queryAllByText('Add comment')[0]);
});
- expect(screen.queryByTestId('tinymce-editor').value).toBe('');
+ expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!');
});
it('successfully added response in the draft.', async () => {
@@ -793,7 +866,7 @@ describe('ThreadView', () => {
fireEvent.click(screen.queryByText('Add response'));
});
- expect(screen.queryByTestId('tinymce-editor').value).toBe('');
+ expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!');
});
it('successfully maintain response for the specific post in the draft.', async () => {
@@ -975,3 +1048,61 @@ describe('ThreadView', () => {
});
});
});
+
+describe('MockReCAPTCHA', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('uses defaultProps when props are not provided', () => {
+ render(