diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 4210fa80..ff9df024 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -8,7 +8,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, Spinner, StatefulButton } from '@openedx/paragon'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; import { Helmet } from 'react-helmet'; import Skeleton from 'react-loading-skeleton'; @@ -32,6 +31,7 @@ import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import useRecaptchaSubmission from './data/hooks'; import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import getBackendValidations from './data/selectors'; @@ -59,10 +59,10 @@ import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracki * Main Registration Page component */ const RegistrationPage = (props) => { - const { executeRecaptcha } = useGoogleReCaptcha(); const { formatMessage } = useIntl(); const dispatch = useDispatch(); + const { executeWithFallback } = useRecaptchaSubmission('submit_registration_form'); const registrationEmbedded = isHostAvailableInQueryParams(); const platformName = getConfig().SITE_NAME; const flags = { @@ -252,7 +252,7 @@ const RegistrationPage = (props) => { fieldDescriptions, formatMessage, ); - setErrors({ ...fieldErrors }); + setErrors({ ...fieldErrors, captchaError: '' }); dispatch(setEmailSuggestionInStore(emailSuggestion)); // returning if not valid @@ -261,38 +261,29 @@ const RegistrationPage = (props) => { return; } - if (executeRecaptcha) { - const recaptchaToken = await executeRecaptcha('submit_registration_form'); - if (!recaptchaToken) { - setErrors(prevErrors => ({ - ...prevErrors, - captchaError: formatMessage(messages['registration.captcha.verification.label']), - })); - return; - } - setErrors(prevErrors => ({ - ...prevErrors, - captchaError: '', + let recaptchaToken = null; + try { + recaptchaToken = await executeWithFallback(); + } catch (err) { + setErrors(prev => ({ + ...prev, + captchaError: formatMessage(messages['registration.captcha.verification.label']) || err.message, })); - - // Preparing payload for submission - if (recaptchaToken) { - payload = prepareRegistrationPayload( - payload, - configurableFormFields, - flags.showMarketingEmailOptInCheckbox, - totalRegistrationTime, - queryParams, - ); - - const updatedpayload = { - ...payload, - captcha_token: recaptchaToken, - }; - // making register call - dispatch(registerNewUser(updatedpayload)); - } + return; } + + payload = prepareRegistrationPayload( + payload, + configurableFormFields, + flags.showMarketingEmailOptInCheckbox, + totalRegistrationTime, + queryParams, + ); + if (recaptchaToken) { + payload = { ...payload, captcha_token: recaptchaToken }; + } + + dispatch(registerNewUser(payload)); }; const handleSubmit = (e) => { diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 1918b6ac..a047bd2e 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,7 @@ import { } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; import mockTagular from '../cohesion/utils'; +import useRecaptchaSubmission from './data/hooks'; import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; @@ -34,6 +35,12 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ getLocale: jest.fn(), })); jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); +jest.mock('./data/hooks', () => ({ + __esModule: true, + default: jest.fn(() => ({ + executeWithFallback: jest.fn(), + })), +})); const mockStore = configureStore(); mockTagular(); @@ -134,6 +141,11 @@ describe('RegistrationPage', () => { }; window.location = { search: '' }; useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue(null), + isReady: true, + isLoading: false, + }); }); afterEach(() => { @@ -175,7 +187,7 @@ describe('RegistrationPage', () => { // ******** test registration form submission ******** - it('should submit form for valid input', () => { + it('should submit form for valid input', async () => { getLocale.mockImplementation(() => ('en-us')); jest.spyOn(global.Date, 'now').mockImplementation(() => 0); @@ -200,10 +212,12 @@ describe('RegistrationPage', () => { const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); }); - it('should submit form without password field when current provider is present', () => { + it('should submit form without password field when current provider is present', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 0); const formPayload = { @@ -233,7 +247,9 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, formPayload, true); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); + }); }); it('should display an error when form is submitted with an invalid email', () => { @@ -284,7 +300,7 @@ describe('RegistrationPage', () => { expect(validationErrors.textContent).toContain(usernameError); }); - it('should submit form with marketing email opt in value', () => { + it('should submit form with marketing email opt in value', async () => { mergeConfig({ MARKETING_EMAILS_OPT_IN: 'true', }); @@ -308,14 +324,16 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, payload); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); mergeConfig({ MARKETING_EMAILS_OPT_IN: '', }); }); - it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', () => { + it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', async () => { mergeConfig({ ENABLE_AUTO_GENERATED_USERNAME: true, }); @@ -335,7 +353,9 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, payload, false, true); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); mergeConfig({ ENABLE_AUTO_GENERATED_USERNAME: false, }); @@ -367,6 +387,124 @@ describe('RegistrationPage', () => { // ******** test registration form validations ******** + it('should submit form with valid reCAPTCHA token', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue('mock-recaptcha-token'), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + app_name: APP_NAME, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + const actions = store.dispatch.mock.calls.map(call => call[0]); + const registerAction = actions.find(a => a.type === registerNewUser().type); + + expect(registerAction).toBeTruthy(); + expect(registerAction.payload).toMatchObject({ + registrationInfo: { + ...payload, + country: 'PK', + captcha_token: 'mock-recaptcha-token', + }, + }); + }); + }); + + it('should display error when reCAPTCHA verification fails', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockRejectedValue(new Error('reCAPTCHA verification failed')), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + const captchaError = container.querySelector('.pgn__form-text-invalid'); + expect(captchaError.textContent).toContain('CAPTCHA verification failed.'); + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ + type: registerNewUser().type, + })); + }); + + it('should submit without reCAPTCHA token if reCAPTCHA is disabled', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue(null), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + app_name: APP_NAME, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith( + registerNewUser({ + ...payload, + country: 'PK', + }), + ); + }); + }); + it('should show error messages for required fields on empty form submission', () => { const { container } = render(routerWrapper(reduxWrapper())); @@ -846,7 +984,7 @@ describe('RegistrationPage', () => { expect(registrationFormElement).toBeFalsy(); }); - it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', () => { + it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 0); getLocale.mockImplementation(() => ('en-us')); @@ -890,15 +1028,17 @@ describe('RegistrationPage', () => { store.dispatch = jest.fn(store.dispatch); render(routerWrapper(reduxWrapper())); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ - name: 'John Doe', - username: 'john_doe', - email: 'john.doe@example.com', - country: 'PK', - social_auth_provider: 'Apple', - total_registration_time: 0, - app_name: APP_NAME, - })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + country: 'PK', + social_auth_provider: 'Apple', + total_registration_time: 0, + app_name: APP_NAME, + })); + }); }); }); }); diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 38e692e3..97589b2d 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -4,7 +4,7 @@ import { mergeConfig } from '@edx/frontend-platform'; import { getLocale, IntlProvider, } from '@edx/frontend-platform/i18n'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; @@ -225,7 +225,7 @@ describe('ConfigurableRegistrationForm', () => { expect(document.querySelector('#tos')).toBeTruthy(); }); - it('should submit form with fields returned by backend in payload', () => { + it('should submit form with fields returned by backend in payload', async () => { mergeConfig({ SHOW_CONFIGURABLE_EDX_FIELDS: true, }); @@ -265,7 +265,9 @@ describe('ConfigurableRegistrationForm', () => { fireEvent.click(submitButton); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); + }); }); it('should show error messages for required fields on empty form submission', () => { diff --git a/src/register/data/hooks.js b/src/register/data/hooks.js new file mode 100644 index 00000000..dab8461f --- /dev/null +++ b/src/register/data/hooks.js @@ -0,0 +1,36 @@ +import { useCallback } from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; + +const useRecaptchaSubmission = (actionName = 'submit') => { + const { executeRecaptcha } = useGoogleReCaptcha(); + const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB; + + const isReady = !!executeRecaptcha || !recaptchaKey; + + const executeWithFallback = useCallback(async () => { + if (executeRecaptcha && recaptchaKey) { + const token = await executeRecaptcha(actionName); + if (!token) { + throw new Error('reCAPTCHA verification failed. Please try again.'); + } + return token; + } + + // Fallback: no reCAPTCHA or not ready + if (recaptchaKey) { + // eslint-disable-next-line no-console + console.warn(`reCAPTCHA not ready for action: ${actionName}. Proceeding without token.`); + } + return null; + }, [executeRecaptcha, recaptchaKey, actionName]); + + return { + executeWithFallback, + isReady, + isLoading: recaptchaKey && !executeRecaptcha, + }; +}; + +export default useRecaptchaSubmission;