diff --git a/src/components/join-flow/email-step.jsx b/src/components/join-flow/email-step.jsx index 6d7b8bff7..c70093c74 100644 --- a/src/components/join-flow/email-step.jsx +++ b/src/components/join-flow/email-step.jsx @@ -25,8 +25,7 @@ class EmailStep extends React.Component { 'validateForm', 'setCaptchaRef', 'captchaSolved', - 'onCaptchaLoad', - 'onCaptchaError' + 'onCaptchaLoad' ]); this.state = { captchaIsLoading: true @@ -49,7 +48,7 @@ class EmailStep extends React.Component { // Load Google ReCaptcha script. const script = document.createElement('script'); script.async = true; - script.onerror = this.onCaptchaError; + script.onerror = this.props.onCaptchaError; script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`; document.body.appendChild(script); } @@ -60,20 +59,13 @@ class EmailStep extends React.Component { handleSetEmailRef (emailInputRef) { this.emailInput = emailInputRef; } - onCaptchaError () { - this.props.onRegistrationError( - this.props.intl.formatMessage({ - id: 'registration.troubleReload' - }) - ); - } onCaptchaLoad () { this.setState({captchaIsLoading: false}); this.grecaptcha = window.grecaptcha; if (!this.grecaptcha) { // According to the reCaptcha documentation, this callback shouldn't get // called unless window.grecaptcha exists. This is just here to be extra defensive. - this.onCaptchaError(); + this.props.onCaptchaError(); return; } this.widgetId = this.grecaptcha.render(this.captchaRef, @@ -234,8 +226,8 @@ class EmailStep extends React.Component { EmailStep.propTypes = { intl: intlShape, + onCaptchaError: PropTypes.func, onNextStep: PropTypes.func, - onRegistrationError: PropTypes.func, waiting: PropTypes.bool }; diff --git a/src/components/join-flow/join-flow-steps.scss b/src/components/join-flow/join-flow-steps.scss index 78df6a2bc..3d7e1df30 100644 --- a/src/components/join-flow/join-flow-steps.scss +++ b/src/components/join-flow/join-flow-steps.scss @@ -33,8 +33,9 @@ .join-flow-instructions { font-size: .875rem; font-weight: bold; - line-height: 1.37500rem; - margin-bottom: 1rem; + line-height: 1.375rem; + margin-top: 1.25rem; + margin-bottom: .5rem; text-align: center; } @@ -161,6 +162,15 @@ padding-bottom: 1rem; } +.join-flow-inner-error-step { + user-select: text; /* make text selectable, so users can copy errors */ + padding-top: 5.5rem; +} + +.join-flow-error-title { + margin-bottom: 2rem; +} + .join-flow-birthdate-title { margin-bottom: 2.875rem; } @@ -177,11 +187,6 @@ background-color: $dd-medium-blue; } -.join-flow-registration-error { - user-select: text; /* make text selectable, so users can copy errors */ - padding-top: 5.5rem; -} - .join-flow-gender-description { margin-top: .625rem; margin-bottom: 1.25rem; @@ -197,11 +202,7 @@ } .join-flow-welcome-title { - margin-bottom: .25rem; -} - -.join-flow-welcome-description { - margin-bottom: 1.25rem; + margin-bottom: 1rem; } .welcome-step-image { diff --git a/src/components/join-flow/join-flow.jsx b/src/components/join-flow/join-flow.jsx index b3139677c..876bab9b6 100644 --- a/src/components/join-flow/join-flow.jsx +++ b/src/components/join-flow/join-flow.jsx @@ -8,6 +8,7 @@ const api = require('../../lib/api'); const injectIntl = require('../../lib/intl.jsx').injectIntl; const intlShape = require('../../lib/intl.jsx').intlShape; const sessionActions = require('../../redux/session.js'); +const validate = require('../../lib/validate'); const Progression = require('../progression/progression.jsx'); const UsernameStep = require('./username-step.jsx'); @@ -23,8 +24,8 @@ class JoinFlow extends React.Component { super(props); bindAll(this, [ 'handleAdvanceStep', + 'handleCaptchaError', 'handleErrorNext', - 'handleRegistrationError', 'handlePrepareToRegister', 'handleRegistrationResponse', 'handleSubmitRegistration' @@ -42,15 +43,17 @@ class JoinFlow extends React.Component { this.state = this.initialState; } canTryAgain () { - return (this.state.numAttempts <= 1); + return (this.state.registrationError.errorAllowsTryAgain && this.state.numAttempts <= 1); } - handleRegistrationError (message) { - if (!message) { - message = this.props.intl.formatMessage({ - id: 'registration.generalError' - }); - } - this.setState({registrationError: message}); + handleCaptchaError () { + this.setState({ + registrationError: { + errorAllowsTryAgain: false, + errorMsg: this.props.intl.formatMessage({ + id: 'registration.errorCaptcha' + }) + } + }); } handlePrepareToRegister (newFormData) { newFormData = newFormData || {}; @@ -61,59 +64,113 @@ class JoinFlow extends React.Component { this.handleSubmitRegistration(this.state.formData); }); } + getErrorsFromResponse (err, body, res) { + const errorsFromResponse = []; + if (!err && res.statusCode === 200 && body && body[0]) { + const responseBodyErrors = body[0].errors; + if (responseBodyErrors) { + Object.keys(responseBodyErrors).forEach(fieldName => { + const errorStrs = responseBodyErrors[fieldName]; + errorStrs.forEach(errorStr => { + errorsFromResponse.push({fieldName: fieldName, errorStr: errorStr}); + }); + }); + } + } + return errorsFromResponse; + } + getCustomErrMsg (errorsFromResponse) { + if (!errorsFromResponse || errorsFromResponse.length === 0) return null; + let customErrMsg = ''; + // body can include zero or more error objects. Here we assemble + // all of them into a single string, customErrMsg. + errorsFromResponse.forEach(errorFromResponse => { + if (customErrMsg.length) customErrMsg += '; '; + customErrMsg += `${errorFromResponse.fieldName}: ${errorFromResponse.errorStr}`; + }); + const problemsStr = this.props.intl.formatMessage({id: 'registration.problemsAre'}); + return `${problemsStr}: "${customErrMsg}"`; + } + registrationIsSuccessful (err, body, res) { + return !!(!err && res.statusCode === 200 && body && body[0] && body[0].success); + } + // example of failing response: + // [ + // { + // "msg": "This field is required.", + // "errors": { + // "username": ["This field is required."], + // "recaptcha": ["Incorrect, please try again."] + // }, + // "success": false + // } + // ] + // + // username messages: + // * "username": ["username exists"] + // * "username": ["invalid username"] (length, charset) + // * "username": ["bad username"] (cleanspeak) + // password messages: + // * "password": ["Ensure this value has at least 6 characters (it has LENGTH_NUM_HERE)."] + // recaptcha messages: + // * "recaptcha": ["This field is required."] + // * "recaptcha": ["Incorrect, please try again."] + // * "recaptcha": [some timeout message?] + // other messages: + // * "birth_month": ["Ensure this value is less than or equal to 12."] + // * "birth_month": ["Ensure this value is greater than or equal to 1."] handleRegistrationResponse (err, body, res) { - // example of failing response: - // [ - // { - // "msg": "This field is required.", - // "errors": { - // "username": ["This field is required."], - // "recaptcha": ["Incorrect, please try again."] - // }, - // "success": false - // } - // ] - // username: 'username exists' this.setState({ numAttempts: this.state.numAttempts + 1, waiting: false }, () => { - let errStr = ''; - if (!err && res.statusCode === 200) { - if (body && body[0]) { - if (body[0].success) { - this.props.refreshSession(); - this.setState({ - step: this.state.step + 1 - }); - return; - } - if (body[0].errors) { - // body can include zero or more error objects, each - // with its own key and description. Here we assemble - // all of them into a single string, errStr. - const errorKeys = Object.keys(body[0].errors); - errorKeys.forEach(key => { - const val = body[0].errors[key]; - if (val && val[0]) { - if (errStr.length) errStr += '; '; - errStr += `${key}: ${val[0]}`; - } - }); - } - if (!errStr.length && body[0].msg) errStr = body[0].msg; + const success = this.registrationIsSuccessful(err, body, res); + if (success) { + this.props.refreshSession(); + this.setState({step: this.state.step + 1}); + return; + } + // now we know something went wrong -- either an actual error (client-side + // or server-side), or just a problem with the registration content. + + // if an actual error, prompt user to try again. + if (err || res.statusCode !== 200) { + this.setState({registrationError: {errorAllowsTryAgain: true}}); + return; + } + + // now we know there was a problem with the registration content. + // If the server provided us info on why registration failed, + // build a summary explanation string + let errorMsg = null; + const errorsFromResponse = this.getErrorsFromResponse(err, body, res); + // if there was exactly one error, check if we have a pre-written message + // about that precise error + if (errorsFromResponse.length === 1) { + const singleErrMsgId = validate.responseErrorMsg( + errorsFromResponse[0].fieldName, + errorsFromResponse[0].errorStr + ); + if (singleErrMsgId) { // one error that we have a predefined explanation string for + errorMsg = this.props.intl.formatMessage({id: singleErrMsgId}); } } + // if we have more than one error, build a custom message with all of the + // server-provided error messages + if (!errorMsg && errorsFromResponse.length > 0) { + errorMsg = this.getCustomErrMsg(errorsFromResponse); + } this.setState({ - registrationError: errStr || - `${this.props.intl.formatMessage({ - id: 'registration.generalError' - })} (${res.statusCode})` + registrationError: { + errorAllowsTryAgain: false, + errorMsg: errorMsg + } }); }); } handleSubmitRegistration (formData) { this.setState({ + registrationError: null, // clear any existing error waiting: true }, () => { api({ @@ -164,7 +221,7 @@ class JoinFlow extends React.Component { {this.state.registrationError ? ( + > +
+ +
+ {this.props.errorMsg && ( +
+ {this.props.errorMsg} +
+ )} + {this.props.canTryAgain ? ( +
+ +
+ ) : ( +
+ +
+ )} + ); } } RegistrationErrorStep.propTypes = { - canTryAgain: PropTypes.bool, + canTryAgain: PropTypes.bool.isRequired, errorMsg: PropTypes.string, intl: intlShape, - onSubmit: PropTypes.func + onSubmit: PropTypes.func.isRequired +}; + +RegistrationErrorStep.defaultProps = { + canTryAgain: false }; const IntlRegistrationErrorStep = injectIntl(RegistrationErrorStep); diff --git a/src/components/join-flow/welcome-step.jsx b/src/components/join-flow/welcome-step.jsx index 24608044a..f4694a1c2 100644 --- a/src/components/join-flow/welcome-step.jsx +++ b/src/components/join-flow/welcome-step.jsx @@ -39,10 +39,6 @@ class WelcomeStep extends React.Component { } = props; return ( +
+ +
tips, tutorials, and guides.", "registration.choosePasswordStepDescription": "Type in a new password for your account. You will use this password the next time you log into Scratch.", @@ -172,6 +173,10 @@ "registration.confirmYourEmailDescription": "If you haven't already, please click the link in the confirmation email sent to:", "registration.createAccount": "Create Your Account", "registration.createUsername": "Create a username", + "registration.errorBadUsername": "The username you chose is not allowed. Try again with a different username.", + "registration.errorCaptcha": "There was a problem with the CAPTCHA test.", + "registration.errorPasswordTooShort": "Your password is too short. It needs to be at least 6 letters long.", + "registration.errorUsernameExists": "The username you chose already exists. Try again with a different username.", "registration.genderStepTitle": "What's your gender?", "registration.genderStepDescription": "Scratch welcomes people of all genders.", "registration.genderStepInfo": "This helps us understand who uses Scratch, so that we can broaden participation. This information will not be made public on your account.", @@ -194,11 +199,14 @@ "registration.personalStepTitle": "Personal Information", "registration.personalStepDescription": "Your individual responses will not be displayed publicly, and will be kept confidential and secure", "registration.private": "We will keep this information private.", + "registration.problemsAre": "The problems are:", "registration.receiveEmails": "I'd like to receive emails from the Scratch Team about project ideas, events, and more.", "registration.selectCountry": "Select country", + "registration.startOverInstruction": "Click \"Start over.\"", "registration.studentPersonalStepDescription": "This information will not appear on the Scratch website.", "registration.showPassword": "Show password", "registration.troubleReload": "Scratch is having trouble finishing registration. Try reloading the page or try again in another browser.", + "registration.tryAgainInstruction": "Click \"Try again\".", "registration.usernameStepDescription": "Fill in the following forms to request an account. The approval process may take up to one day.", "registration.usernameStepDescriptionNonEducator": "Create projects, share ideas, make friends. It’s free!", "registration.usernameStepRealName": "Please do not use any portion of your real name in your username.", diff --git a/src/lib/validate.js b/src/lib/validate.js index 5df20b0e4..65efcc03e 100644 --- a/src/lib/validate.js +++ b/src/lib/validate.js @@ -101,3 +101,30 @@ module.exports.validateEmailRemotely = email => ( }); }) ); + +const responseErrorMsgs = module.exports.responseErrorMsgs = { + username: { + 'username exists': {errMsgId: 'registration.errorUsernameExists'}, + 'bad username': {errMsgId: 'registration.errorBadUsername'} + }, + password: { + 'Ensure this value has at least 6 characters \\(it has \\d\\).': { + errMsgId: 'registration.errorPasswordTooShort' + } + }, + recaptcha: { + 'Incorrect, please try again.': {errMsgId: 'registration.errorCaptcha'} + } +}; + +module.exports.responseErrorMsg = (fieldName, serverRawErr) => { + if (fieldName && responseErrorMsgs[fieldName]) { + const serverErrPatterns = responseErrorMsgs[fieldName]; + // use regex compare to find matching error string in responseErrorMsgs + const matchingKey = Object.keys(serverErrPatterns).find(errPattern => ( + RegExp(errPattern).test(serverRawErr) + )); + if (matchingKey) return responseErrorMsgs[fieldName][matchingKey].errMsgId; + } + return null; +}; diff --git a/test/unit/components/email-step.test.jsx b/test/unit/components/email-step.test.jsx index 9896d84f9..f74eebc9c 100644 --- a/test/unit/components/email-step.test.jsx +++ b/test/unit/components/email-step.test.jsx @@ -26,23 +26,23 @@ describe('EmailStep test', () => { }); test('send correct props to formik', () => { - const wrapper = shallowWithIntl(); + const intlWrapper = shallowWithIntl(); - const formikWrapper = wrapper.dive(); - expect(formikWrapper.props().initialValues.subscribe).toBe(false); - expect(formikWrapper.props().initialValues.email).toBe(''); - expect(formikWrapper.props().validateOnBlur).toBe(false); - expect(formikWrapper.props().validateOnChange).toBe(false); - expect(formikWrapper.props().validate).toBe(formikWrapper.instance().validateForm); - expect(formikWrapper.props().onSubmit).toBe(formikWrapper.instance().handleValidSubmit); + const emailStepWrapper = intlWrapper.dive(); + expect(emailStepWrapper.props().initialValues.subscribe).toBe(false); + expect(emailStepWrapper.props().initialValues.email).toBe(''); + expect(emailStepWrapper.props().validateOnBlur).toBe(false); + expect(emailStepWrapper.props().validateOnChange).toBe(false); + expect(emailStepWrapper.props().validate).toBe(emailStepWrapper.instance().validateForm); + expect(emailStepWrapper.props().onSubmit).toBe(emailStepWrapper.instance().handleValidSubmit); }); test('props sent to JoinFlowStep', () => { - const wrapper = shallowWithIntl(); + const intlWrapper = shallowWithIntl(); // Dive to get past the intl wrapper - const formikWrapper = wrapper.dive(); + const emailStepWrapper = intlWrapper.dive(); // Dive to get past the anonymous component. - const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep); + const joinFlowWrapper = emailStepWrapper.dive().find(JoinFlowStep); expect(joinFlowWrapper).toHaveLength(1); expect(joinFlowWrapper.props().footerContent.props.id).toBe('registration.acceptTermsOfUse'); expect(joinFlowWrapper.props().headerImgSrc).toBe('/images/join-flow/email-header.png'); @@ -54,11 +54,11 @@ describe('EmailStep test', () => { }); test('props sent to FormikInput for email', () => { - const wrapper = shallowWithIntl(); + const intlWrapper = shallowWithIntl(); // Dive to get past the intl wrapper - const formikWrapper = wrapper.dive(); + const emailStepWrapper = intlWrapper.dive(); // Dive to get past the anonymous component. - const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep); + const joinFlowWrapper = emailStepWrapper.dive().find(JoinFlowStep); expect(joinFlowWrapper).toHaveLength(1); const emailInputWrapper = joinFlowWrapper.find(FormikInput).first(); expect(emailInputWrapper.props().id).toEqual('email'); @@ -66,16 +66,16 @@ describe('EmailStep test', () => { expect(emailInputWrapper.props().name).toEqual('email'); expect(emailInputWrapper.props().placeholder).toEqual('general.emailAddress'); expect(emailInputWrapper.props().validationClassName).toEqual('validation-full-width-input'); - expect(emailInputWrapper.props().onSetRef).toEqual(formikWrapper.instance().handleSetEmailRef); - expect(emailInputWrapper.props().validate).toEqual(formikWrapper.instance().validateEmail); + expect(emailInputWrapper.props().onSetRef).toEqual(emailStepWrapper.instance().handleSetEmailRef); + expect(emailInputWrapper.props().validate).toEqual(emailStepWrapper.instance().validateEmail); }); test('props sent to FormikCheckbox for subscribe', () => { - const wrapper = shallowWithIntl(); + const intlWrapper = shallowWithIntl(); // Dive to get past the intl wrapper - const formikWrapper = wrapper.dive(); + const emailStepWrapper = intlWrapper.dive(); // Dive to get past the anonymous component. - const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep); + const joinFlowWrapper = emailStepWrapper.dive().find(JoinFlowStep); expect(joinFlowWrapper).toHaveLength(1); const checkboxWrapper = joinFlowWrapper.find(FormikCheckbox).first(); expect(checkboxWrapper).toHaveLength(1); @@ -93,12 +93,12 @@ describe('EmailStep test', () => { render: jest.fn() }; const formData = {item1: 'thing', item2: 'otherthing'}; - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const formikWrapper = wrapper.dive(); - formikWrapper.instance().onCaptchaLoad(); // to setup catpcha state - formikWrapper.instance().handleValidSubmit(formData, formikBag); + const emailStepWrapper = intlWrapper.dive(); + emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state + emailStepWrapper.instance().handleValidSubmit(formData, formikBag); expect(formikBag.setSubmitting).toHaveBeenCalledWith(false); expect(global.grecaptcha.execute).toHaveBeenCalled(); @@ -116,18 +116,18 @@ describe('EmailStep test', () => { render: jest.fn() }; const formData = {item1: 'thing', item2: 'otherthing'}; - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const formikWrapper = wrapper.dive(); + const emailStepWrapper = intlWrapper.dive(); // Call these to setup captcha. - formikWrapper.instance().onCaptchaLoad(); // to setup catpcha state - formikWrapper.instance().handleValidSubmit(formData, formikBag); + emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state + emailStepWrapper.instance().handleValidSubmit(formData, formikBag); const captchaToken = 'abcd'; - formikWrapper.instance().captchaSolved(captchaToken); + emailStepWrapper.instance().captchaSolved(captchaToken); // Make sure captchaSolved calls onNextStep with formData that has // a captcha token and left everything else in the object in place. expect(props.onNextStep).toHaveBeenCalledWith( @@ -139,65 +139,51 @@ describe('EmailStep test', () => { expect(formikBag.setSubmitting).toHaveBeenCalledWith(true); }); - test('onCaptchaError calls error function with correct message', () => { - const props = { - onRegistrationError: jest.fn() - }; - - const wrapper = shallowWithIntl( - ); - - const formikWrapper = wrapper.dive(); - formikWrapper.instance().onCaptchaError(); - expect(props.onRegistrationError).toHaveBeenCalledWith('registration.troubleReload'); - }); - test('Captcha load error calls error function', () => { const props = { - onRegistrationError: jest.fn() + onCaptchaError: jest.fn() }; // Set this to null to force an error. global.grecaptcha = null; - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); + /> + ); - const formikWrapper = wrapper.dive(); - formikWrapper.instance().onCaptchaLoad(); - expect(props.onRegistrationError).toHaveBeenCalledWith('registration.troubleReload'); + const emailStepWrapper = intlWrapper.dive(); + emailStepWrapper.instance().onCaptchaLoad(); + expect(props.onCaptchaError).toHaveBeenCalled(); }); test('validateEmail test email empty', () => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const formikWrapper = wrapper.dive(); - const val = formikWrapper.instance().validateEmail(''); + const emailStepWrapper = intlWrapper.dive(); + const val = emailStepWrapper.instance().validateEmail(''); expect(val).toBe('general.required'); }); test('validateEmail test email null', () => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const formikWrapper = wrapper.dive(); - const val = formikWrapper.instance().validateEmail(null); + const emailStepWrapper = intlWrapper.dive(); + const val = emailStepWrapper.instance().validateEmail(null); expect(val).toBe('general.required'); }); test('validateEmail test email undefined', () => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const formikWrapper = wrapper.dive(); - const val = formikWrapper.instance().validateEmail(); + const emailStepWrapper = intlWrapper.dive(); + const val = emailStepWrapper.instance().validateEmail(); expect(val).toBe('general.required'); }); test('validateEmailRemotelyWithCache calls validate.validateEmailRemotely', done => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const instance = wrapper.dive().instance(); + const instance = intlWrapper.dive().instance(); instance.validateEmailRemotelyWithCache('some-email@some-domain.com') .then(response => { @@ -209,10 +195,10 @@ describe('EmailStep test', () => { }); test('validateEmailRemotelyWithCache, called twice with different data, makes two remote requests', done => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const instance = wrapper.dive().instance(); + const instance = intlWrapper.dive().instance(); instance.validateEmailRemotelyWithCache('some-email@some-domain.com') .then(response => { @@ -233,10 +219,10 @@ describe('EmailStep test', () => { }); test('validateEmailRemotelyWithCache, called twice with same data, only makes one remote request', done => { - const wrapper = shallowWithIntl( + const intlWrapper = shallowWithIntl( ); - const instance = wrapper.dive().instance(); + const instance = intlWrapper.dive().instance(); instance.validateEmailRemotelyWithCache('some-email@some-domain.com') .then(response => { diff --git a/test/unit/components/join-flow.test.jsx b/test/unit/components/join-flow.test.jsx index 8724c8ec4..66505d1b8 100644 --- a/test/unit/components/join-flow.test.jsx +++ b/test/unit/components/join-flow.test.jsx @@ -9,6 +9,35 @@ import RegistrationErrorStep from '../../../src/components/join-flow/registratio describe('JoinFlow', () => { const mockStore = configureStore(); let store; + const responseBodyMultipleErrs = [ + { + msg: 'This field is required.', + errors: { + username: ['This field is required.'], + recaptcha: ['Incorrect, please try again.'] + }, + success: false + } + ]; + const responseBodySingleErr = [ + { + msg: 'This field is required.', + errors: { + recaptcha: ['Incorrect, please try again.'] + }, + success: false + } + ]; + const responseBodySuccess = [ + { + msg: 'This field is required.', + errors: { + recaptcha: ['Incorrect, please try again.'] + }, + success: true + } + ]; + beforeEach(() => { store = mockStore({sessionActions: { @@ -28,118 +57,14 @@ describe('JoinFlow', () => { .dive(); // unwrap injectIntl(JoinFlow) }; - test('handleRegistrationResponse with successful response', () => { - const props = { - refreshSession: jest.fn() - }; - const joinFlowInstance = getJoinFlowWrapper(props).instance(); - const responseErr = null; - const responseBody = [ - { - success: true - } - ]; - const responseObj = { - statusCode: 200 - }; - joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); - expect(joinFlowInstance.props.refreshSession).toHaveBeenCalled(); - expect(joinFlowInstance.state.registrationError).toBe(null); - }); - - test('handleRegistrationResponse with healthy response, indicating failure', () => { - const props = { - refreshSession: jest.fn() - }; - const joinFlowInstance = getJoinFlowWrapper(props).instance(); - const responseErr = null; - const responseBody = [ - { - msg: 'This field is required.', - errors: { - username: ['This field is required.'] - }, - success: false - } - ]; - const responseObj = { - statusCode: 200 - }; - joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); - expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); - expect(joinFlowInstance.state.registrationError).toBe('username: This field is required.'); - }); - - test('handleRegistrationResponse with failure response, with error fields missing', () => { - const props = { - refreshSession: jest.fn() - }; - const joinFlowInstance = getJoinFlowWrapper(props).instance(); - const responseErr = null; - const responseBody = [ - { - msg: 'This field is required.', - success: false - } - ]; - const responseObj = { - statusCode: 200 - }; - joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); - expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); - expect(joinFlowInstance.state.registrationError).toBe('This field is required.'); - }); - - test('handleRegistrationResponse with failure response, with no text explanation', () => { - const props = { - refreshSession: jest.fn() - }; - const joinFlowInstance = getJoinFlowWrapper(props).instance(); - const responseErr = null; - const responseBody = [ - { - success: false - } - ]; - const responseObj = { - statusCode: 200 - }; - joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); - expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); - expect(joinFlowInstance.state.registrationError).toBe('registration.generalError (200)'); - }); - - test('handleRegistrationResponse with failure status code', () => { - const props = { - refreshSession: jest.fn() - }; - const joinFlowInstance = getJoinFlowWrapper(props).instance(); - const responseErr = null; - const responseBody = [ - { - success: false - } - ]; - const responseObj = { - statusCode: 400 - }; - joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); - expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); - expect(joinFlowInstance.state.registrationError).toBe('registration.generalError (400)'); - }); - - test('handleRegistrationError with no message ', () => { + test('handleCaptchaError gives state with captcha message', () => { const joinFlowInstance = getJoinFlowWrapper().instance(); joinFlowInstance.setState({}); - joinFlowInstance.handleRegistrationError(); - expect(joinFlowInstance.state.registrationError).toBe('registration.generalError'); - }); - - test('handleRegistrationError with message ', () => { - const joinFlowInstance = getJoinFlowWrapper().instance(); - joinFlowInstance.setState({}); - joinFlowInstance.handleRegistrationError('my message'); - expect(joinFlowInstance.state.registrationError).toBe('my message'); + joinFlowInstance.handleCaptchaError(); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: false, + errorMsg: 'registration.errorCaptcha' + }); }); test('handleAdvanceStep', () => { @@ -178,39 +103,64 @@ describe('JoinFlow', () => { expect(progressionWrapper).toHaveLength(1); }); - test('when numAttempts is 0, RegistrationErrorStep receives canTryAgain prop with value true', () => { + test('when numAttempts is 0 and registrationError errorAllowsTryAgain is true, ' + + 'RegistrationErrorStep receives errorAllowsTryAgain prop with value true', () => { const joinFlowWrapper = getJoinFlowWrapper(); joinFlowWrapper.instance().setState({ numAttempts: 0, - registrationError: 'halp there is a errors!!' + registrationError: { + errorAllowsTryAgain: true, + errorMsg: 'halp there is a errors!!' + } }); const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep); expect(registrationErrorWrapper.first().props().canTryAgain).toEqual(true); }); - test('when numAttempts is 1, RegistrationErrorStep receives canTryAgain prop with value true', () => { + test('when numAttempts is 1 and registrationError errorAllowsTryAgain is true, ' + + 'RegistrationErrorStep receives errorAllowsTryAgain prop with value true', () => { const joinFlowWrapper = getJoinFlowWrapper(); joinFlowWrapper.instance().setState({ numAttempts: 1, - registrationError: 'halp there is a errors!!' + registrationError: { + errorAllowsTryAgain: true, + errorMsg: 'halp there is a errors!!' + } }); const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep); expect(registrationErrorWrapper.first().props().canTryAgain).toEqual(true); }); - test('when numAttempts is 2, RegistrationErrorStep receives canTryAgain prop with value false', () => { + test('when numAttempts is 2 and registrationError errorAllowsTryAgain is true, ' + + 'RegistrationErrorStep receives errorAllowsTryAgain prop with value false', () => { const joinFlowWrapper = getJoinFlowWrapper(); joinFlowWrapper.instance().setState({ numAttempts: 2, - registrationError: 'halp there is a errors!!' + registrationError: { + errorAllowsTryAgain: true, + errorMsg: 'halp there is a errors!!' + } + }); + const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep); + expect(registrationErrorWrapper.first().props().canTryAgain).toEqual(false); + }); + + test('when numAttempts is 0 and registrationError errorAllowsTryAgain is false, ' + + 'RegistrationErrorStep receives errorAllowsTryAgain prop with value false', () => { + const joinFlowWrapper = getJoinFlowWrapper(); + joinFlowWrapper.instance().setState({ + numAttempts: 0, + registrationError: { + errorAllowsTryAgain: false, + errorMsg: 'halp there is a errors!!' + } }); const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep); expect(registrationErrorWrapper.first().props().canTryAgain).toEqual(false); }); test('resetState resets entire state, does not leave any state keys out', () => { - const joinFlowWrapper = getJoinFlowWrapper(); - const joinFlowInstance = joinFlowWrapper.instance(); + const joinFlowInstance = getJoinFlowWrapper().instance(); Object.keys(joinFlowInstance.state).forEach(key => { joinFlowInstance.setState({[key]: 'Different than the initial value'}); }); @@ -221,8 +171,7 @@ describe('JoinFlow', () => { }); test('resetState makes each state field match initial state', () => { - const joinFlowWrapper = getJoinFlowWrapper(); - const joinFlowInstance = joinFlowWrapper.instance(); + const joinFlowInstance = getJoinFlowWrapper().instance(); const stateSnapshot = {}; Object.keys(joinFlowInstance.state).forEach(key => { stateSnapshot[key] = joinFlowInstance.state[key]; @@ -234,8 +183,7 @@ describe('JoinFlow', () => { }); test('calling resetState results in state.formData which is not same reference as before', () => { - const joinFlowWrapper = getJoinFlowWrapper(); - const joinFlowInstance = joinFlowWrapper.instance(); + const joinFlowInstance = getJoinFlowWrapper().instance(); joinFlowInstance.setState({ formData: defaults({}, {username: 'abcdef'}) }); @@ -244,4 +192,185 @@ describe('JoinFlow', () => { expect(formDataReference).not.toBe(joinFlowInstance.state.formData); expect(formDataReference).not.toEqual(joinFlowInstance.state.formData); }); + + test('getErrorsFromResponse returns object of errors', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse(null, responseBodyMultipleErrs, {statusCode: 200}); + expect(errorsFromResponse).toEqual([ + { + fieldName: 'username', + errorStr: 'This field is required.' + }, { + fieldName: 'recaptcha', + errorStr: 'Incorrect, please try again.' + } + ]); + }); + + test('getErrorsFromResponse called with non-null err returns empty array', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse({}, responseBodyMultipleErrs, {statusCode: 200}); + expect(errorsFromResponse).toEqual([]); + }); + + test('getErrorsFromResponse called with non-200 status code returns empty array', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse({}, responseBodyMultipleErrs, {statusCode: 400}); + expect(errorsFromResponse).toEqual([]); + }); + + test('getErrorsFromResponse gets single error, when given response body with only one error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse(null, responseBodySingleErr, {statusCode: 200}); + expect(errorsFromResponse.length).toEqual(1); + }); + + test('getCustomErrMsg string when given response body with multiple errors', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse(null, responseBodyMultipleErrs, {statusCode: 200}); + const customErrMsg = joinFlowInstance.getCustomErrMsg(errorsFromResponse); + expect(customErrMsg).toEqual('registration.problemsAre: "username: This field is required.; ' + + 'recaptcha: Incorrect, please try again."'); + }); + + test('getCustomErrMsg string when given response body with single error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const errorsFromResponse = + joinFlowInstance.getErrorsFromResponse(null, responseBodySingleErr, {statusCode: 200}); + const customErrMsg = joinFlowInstance.getCustomErrMsg(errorsFromResponse); + expect(customErrMsg).toEqual('registration.problemsAre: "recaptcha: Incorrect, please try again."'); + }); + + test('registrationIsSuccessful returns true when given response body with single error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const success = joinFlowInstance.registrationIsSuccessful(null, responseBodySuccess, {statusCode: 200}); + expect(success).toEqual(true); + }); + + test('registrationIsSuccessful returns false when given status code not 200', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const success = joinFlowInstance.registrationIsSuccessful(null, responseBodySuccess, {statusCode: 500}); + expect(success).toEqual(false); + }); + + test('registrationIsSuccessful returns false when given body with success field false', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const success = joinFlowInstance.registrationIsSuccessful(null, responseBodySingleErr, {statusCode: 200}); + expect(success).toEqual(false); + }); + + test('registrationIsSuccessful returns false when given non null err', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + const success = joinFlowInstance.registrationIsSuccessful({}, responseBodySuccess, {statusCode: 200}); + expect(success).toEqual(false); + }); + + test('handleRegistrationResponse when passed body with success', () => { + const props = { + refreshSession: jest.fn() + }; + const joinFlowInstance = getJoinFlowWrapper(props).instance(); + joinFlowInstance.handleRegistrationResponse(null, responseBodySuccess, {statusCode: 200}); + expect(joinFlowInstance.state.registrationError).toEqual(null); + expect(joinFlowInstance.props.refreshSession).toHaveBeenCalled(); + expect(joinFlowInstance.state.step).toEqual(1); + expect(joinFlowInstance.state.waiting).toBeFalsy(); + }); + + test('handleRegistrationResponse when passed body with preset server error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + joinFlowInstance.handleRegistrationResponse(null, responseBodySingleErr, {statusCode: 200}); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: false, + errorMsg: 'registration.errorCaptcha' + }); + }); + + test('handleRegistrationResponse with failure response, with error fields missing', () => { + const props = { + refreshSession: jest.fn() + }; + const joinFlowInstance = getJoinFlowWrapper(props).instance(); + const responseErr = null; + const responseBody = [ + { + msg: 'This field is required.', + success: false + } + ]; + const responseObj = { + statusCode: 200 + }; + joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); + expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: false, + errorMsg: null + }); + }); + + test('handleRegistrationResponse when passed body with unfamiliar server error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + joinFlowInstance.handleRegistrationResponse(null, responseBodyMultipleErrs, {statusCode: 200}); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: false, + errorMsg: 'registration.problemsAre: "username: This field is required.; ' + + 'recaptcha: Incorrect, please try again."' + }); + }); + + test('handleRegistrationResponse with failure response, with no text explanation', () => { + const props = { + refreshSession: jest.fn() + }; + const joinFlowInstance = getJoinFlowWrapper(props).instance(); + const responseErr = null; + const responseBody = [ + { + success: false + } + ]; + const responseObj = { + statusCode: 200 + }; + joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj); + expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: false, + errorMsg: null + }); + }); + + test('handleRegistrationResponse when passed non null outgoing request error', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + joinFlowInstance.handleRegistrationResponse({}, responseBodyMultipleErrs, {statusCode: 200}); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: true + }); + }); + + test('handleRegistrationResponse when passed status 400', () => { + const props = { + refreshSession: jest.fn() + }; + const joinFlowInstance = getJoinFlowWrapper(props).instance(); + joinFlowInstance.handleRegistrationResponse({}, responseBodyMultipleErrs, {statusCode: 400}); + expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled(); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: true + }); + }); + + test('handleRegistrationResponse when passed status 500', () => { + const joinFlowInstance = getJoinFlowWrapper().instance(); + joinFlowInstance.handleRegistrationResponse(null, responseBodyMultipleErrs, {statusCode: 500}); + expect(joinFlowInstance.state.registrationError).toEqual({ + errorAllowsTryAgain: true + }); + }); }); diff --git a/test/unit/components/registration-error-step.test.jsx b/test/unit/components/registration-error-step.test.jsx index afad5e63a..97876db95 100644 --- a/test/unit/components/registration-error-step.test.jsx +++ b/test/unit/components/registration-error-step.test.jsx @@ -9,8 +9,6 @@ describe('RegistrationErrorStep', () => { const getRegistrationErrorStepWrapper = props => { const wrapper = shallowWithIntl( ); @@ -18,31 +16,90 @@ describe('RegistrationErrorStep', () => { .dive(); // unwrap injectIntl() }; - test('when canTryAgain is true, show tryAgain message', () => { - const props = {canTryAgain: true}; + test('registrationError has JoinFlowStep', () => { + const props = { + canTryAgain: true, + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); + expect(joinFlowStepWrapper).toHaveLength(1); + }); + + test('when errorMsg provided, registrationError shows it', () => { + const props = { + canTryAgain: true, + errorMsg: 'halp there is a errors!!', + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); + const joinFlowStepInstance = joinFlowStepWrapper.dive(); + const errMsgElement = joinFlowStepInstance.find('.registration-error-msg'); + expect(errMsgElement).toHaveLength(1); + expect(errMsgElement.text()).toEqual('halp there is a errors!!'); + }); + + test('when errorMsg is null, registrationError does not show it', () => { + const props = { + canTryAgain: true, + errorMsg: null, + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); + const joinFlowStepInstance = joinFlowStepWrapper.dive(); + const errMsgElement = joinFlowStepInstance.find('.registration-error-msg'); + expect(errMsgElement).toHaveLength(0); + }); + + test('when no errorMsg provided, registrationError does not show it', () => { + const props = { + canTryAgain: true, + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); + const joinFlowStepInstance = joinFlowStepWrapper.dive(); + const errMsgElement = joinFlowStepInstance.find('.registration-error-msg'); + expect(errMsgElement).toHaveLength(0); + }); + + test('when canTryAgain is true, show tryAgain message', () => { + const props = { + canTryAgain: true, + errorMsg: 'halp there is a errors!!', + onSubmit: onSubmit + }; const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); expect(joinFlowStepWrapper).toHaveLength(1); - expect(joinFlowStepWrapper.props().description).toBe('error message'); expect(joinFlowStepWrapper.props().nextButton).toBe('general.tryAgain'); }); test('when canTryAgain is false, show startOver message', () => { - const props = {canTryAgain: false}; + const props = { + canTryAgain: false, + errorMsg: 'halp there is a errors!!', + onSubmit: onSubmit + }; const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); expect(joinFlowStepWrapper).toHaveLength(1); - expect(joinFlowStepWrapper.props().description).toBe('error message'); expect(joinFlowStepWrapper.props().nextButton).toBe('general.startOver'); }); test('when canTryAgain is missing, show startOver message', () => { - const joinFlowStepWrapper = getRegistrationErrorStepWrapper().find(JoinFlowStep); + const props = { + errorMsg: 'halp there is a errors!!', + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); expect(joinFlowStepWrapper).toHaveLength(1); - expect(joinFlowStepWrapper.props().description).toBe('error message'); expect(joinFlowStepWrapper.props().nextButton).toBe('general.startOver'); }); test('when submitted, onSubmit is called', () => { - const joinFlowStepWrapper = getRegistrationErrorStepWrapper().find(JoinFlowStep); + const props = { + canTryAgain: true, + errorMsg: 'halp there is a errors!!', + onSubmit: onSubmit + }; + const joinFlowStepWrapper = getRegistrationErrorStepWrapper(props).find(JoinFlowStep); joinFlowStepWrapper.props().onSubmit(new Event('event')); // eslint-disable-line no-undef expect(onSubmit).toHaveBeenCalled(); }); diff --git a/test/unit/lib/validate.test.js b/test/unit/lib/validate.test.js index 6074736d5..d34eef808 100644 --- a/test/unit/lib/validate.test.js +++ b/test/unit/lib/validate.test.js @@ -139,4 +139,16 @@ describe('unit test lib/validate.js', () => { response = validate.validateEmailLocally('much."more unusual"@example.com'); expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'}); }); + + test('get responseErrorMsg in cases where there is a dedicated string for that case', () => { + let response = validate.responseErrorMsg('username', 'bad username'); + expect(response).toEqual('registration.errorBadUsername'); + response = validate.responseErrorMsg('password', 'Ensure this value has at least 6 characters (it has 3).'); + expect(response).toEqual('registration.errorPasswordTooShort'); + }); + + test('responseErrorMsg is null in case where there is no dedicated string for that case', () => { + let response = validate.responseErrorMsg('username', 'some error that is not covered'); + expect(response).toEqual(null); + }); });