Unverified Commit bb4e6c08 authored by Benjamin Wheeler's avatar Benjamin Wheeler Committed by GitHub

Merge pull request #3473 from benjiwheeler/join-flow-particular-server-errors

Join flow: custom error messages, retry rules for various particular registration error causes
parents f972ee85 b2e7a0c9
......@@ -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
};
......
......@@ -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 {
......
......@@ -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'
});
handleCaptchaError () {
this.setState({
registrationError: {
errorAllowsTryAgain: false,
errorMsg: this.props.intl.formatMessage({
id: 'registration.errorCaptcha'
})
}
this.setState({registrationError: message});
});
}
handlePrepareToRegister (newFormData) {
newFormData = newFormData || {};
......@@ -61,7 +64,36 @@ class JoinFlow extends React.Component {
this.handleSubmitRegistration(this.state.formData);
});
}
handleRegistrationResponse (err, body, res) {
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:
// [
// {
......@@ -73,47 +105,72 @@ class JoinFlow extends React.Component {
// "success": false
// }
// ]
// username: 'username exists'
//
// 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) {
this.setState({
numAttempts: this.state.numAttempts + 1,
waiting: false
}, () => {
let errStr = '';
if (!err && res.statusCode === 200) {
if (body && body[0]) {
if (body[0].success) {
const success = this.registrationIsSuccessful(err, body, res);
if (success) {
this.props.refreshSession();
this.setState({
step: this.state.step + 1
});
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]}`;
// 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 (!errStr.length && body[0].msg) errStr = body[0].msg;
}
// 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 ? (
<RegistrationErrorStep
canTryAgain={this.canTryAgain()}
errorMsg={this.state.registrationError}
errorMsg={this.state.registrationError.errorMsg}
/* eslint-disable react/jsx-no-bind */
onSubmit={this.handleErrorNext}
/* eslint-enable react/jsx-no-bind */
......@@ -177,8 +234,8 @@ class JoinFlow extends React.Component {
<GenderStep onNextStep={this.handleAdvanceStep} />
<EmailStep
waiting={this.state.waiting}
onCaptchaError={this.handleCaptchaError}
onNextStep={this.handlePrepareToRegister}
onRegistrationError={this.handleRegistrationError}
/>
<WelcomeStep
createProjectOnComplete={this.props.createProjectOnComplete}
......
const bindAll = require('lodash.bindall');
const React = require('react');
const PropTypes = require('prop-types');
const FormattedMessage = require('react-intl').FormattedMessage;
const {injectIntl, intlShape} = require('react-intl');
const JoinFlowStep = require('./join-flow-step.jsx');
......@@ -24,24 +25,46 @@ class RegistrationErrorStep extends React.Component {
render () {
return (
<JoinFlowStep
description={this.props.errorMsg}
innerClassName="join-flow-registration-error"
innerClassName="join-flow-inner-error-step"
nextButton={this.props.canTryAgain ?
this.props.intl.formatMessage({id: 'general.tryAgain'}) :
this.props.intl.formatMessage({id: 'general.startOver'})
}
title={this.props.intl.formatMessage({id: 'registration.generalError'})}
title={this.props.intl.formatMessage({id: 'general.error'})}
titleClassName="join-flow-error-title"
onSubmit={this.handleSubmit}
/>
>
<div className="join-flow-instructions">
<FormattedMessage id="registration.cantCreateAccount" />
</div>
{this.props.errorMsg && (
<div className="join-flow-instructions registration-error-msg">
{this.props.errorMsg}
</div>
)}
{this.props.canTryAgain ? (
<div className="join-flow-instructions">
<FormattedMessage id="registration.tryAgainInstruction" />
</div>
) : (
<div className="join-flow-instructions">
<FormattedMessage id="registration.startOverInstruction" />
</div>
)}
</JoinFlowStep>
);
}
}
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);
......
......@@ -39,10 +39,6 @@ class WelcomeStep extends React.Component {
} = props;
return (
<JoinFlowStep
description={this.props.intl.formatMessage({
id: 'registration.welcomeStepDescriptionNonEducator'
})}
descriptionClassName="join-flow-welcome-description"
headerImgClass="welcome-step-image"
headerImgSrc="/images/join-flow/welcome-header.png"
innerClassName="join-flow-inner-welcome-step"
......@@ -65,6 +61,11 @@ class WelcomeStep extends React.Component {
waiting={isSubmitting}
onSubmit={handleSubmit}
>
<div className="join-flow-instructions">
<FormattedMessage
id="registration.welcomeStepDescriptionNonEducator"
/>
</div>
<div className="join-flow-instructions">
<FormattedMessage
id="registration.welcomeStepInstructions"
......
......@@ -157,6 +157,7 @@
"registration.birthDateStepInfo": "This helps us understand the age range of people who use Scratch. We use this to confirm account ownership if you contact our team. This information will not be made public on your account.",
"registration.birthDateStepTitle": "When were you born?",
"registration.cantCreateAccount": "Scratch could not create your account.",
"registration.checkOutResources": "Get Started with Resources",
"registration.checkOutResourcesDescription": "Explore materials for educators and facilitators written by the Scratch Team, including <a href='/educators#resources'>tips, tutorials, and guides</a>.",
"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.",
......
......@@ -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;
};
......@@ -26,23 +26,23 @@ describe('EmailStep test', () => {
});
test('send correct props to formik', () => {
const wrapper = shallowWithIntl(<EmailStep />);
const intlWrapper = shallowWithIntl(<EmailStep />);
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(<EmailStep />);
const intlWrapper = shallowWithIntl(<EmailStep />);
// 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(<EmailStep />);
const intlWrapper = shallowWithIntl(<EmailStep />);
// 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(<EmailStep />);
const intlWrapper = shallowWithIntl(<EmailStep />);
// 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(
<EmailStep />);
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(
<EmailStep
{...props}
/>);
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(
<EmailStep
{...props}
/>);
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(
<EmailStep
{...props}
/>);
/>
);
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(
<EmailStep />);
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(
<EmailStep />);
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(
<EmailStep />);
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(
<EmailStep />);
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(
<EmailStep />
);
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(
<EmailStep />
);
const instance = wrapper.dive().instance();
const instance = intlWrapper.dive().instance();
instance.validateEmailRemotelyWithCache('some-email@some-domain.com')
.then(response => {
......
......@@ -9,137 +9,62 @@ import RegistrationErrorStep from '../../../src/components/join-flow/registratio
describe('JoinFlow', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({sessionActions: {
refreshSession: jest.fn()
}});
});
const getJoinFlowWrapper = props => {
const wrapper = shallowWithIntl(
<JoinFlow
{...props}
/>
, {context: {store}}
);
return wrapper
.dive() // unwrap redux connect(injectIntl(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 = [
const responseBodyMultipleErrs = [
{
success: true
msg: 'This field is required.',
errors: {
username: ['This field is required.'],
recaptcha: ['Incorrect, please try again.']
},
success: false
}
];
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 = [
const responseBodySingleErr = [
{
msg: 'This field is required.',
errors: {
username: ['This field is required.']
recaptcha: ['Incorrect, please try again.']
},
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 = [
const responseBodySuccess = [
{
msg: 'This field is required.',
success: false
errors: {
recaptcha: ['Incorrect, please try again.']
},
success: true
}
];
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 = {
beforeEach(() => {
store = mockStore({sessionActions: {
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
const getJoinFlowWrapper = props => {
const wrapper = shallowWithIntl(
<JoinFlow
{...props}
/>
, {context: {store}}
);
return wrapper
.dive() // unwrap redux connect(injectIntl(JoinFlow))
.dive(); // unwrap injectIntl(JoinFlow)
};
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');
joinFlowInstance.handleCaptchaError();
expect(joinFlowInstance.state.registrationError).toEqual({
errorAllowsTryAgain: false,
errorMsg: 'registration.errorCaptcha'
});
test('handleRegistrationError with message ', () => {
const joinFlowInstance = getJoinFlowWrapper().instance();
joinFlowInstance.setState({});
joinFlowInstance.handleRegistrationError('my message');
expect(joinFlowInstance.state.registrationError).toBe('my message');
});
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('resetState resets entire state, does not leave any state keys out', () => {
test('when numAttempts is 0 and registrationError errorAllowsTryAgain is false, ' +
'RegistrationErrorStep receives errorAllowsTryAgain prop with value false', () => {
const joinFlowWrapper = getJoinFlowWrapper();
const joinFlowInstance = joinFlowWrapper.instance();
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 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
});
});
});
......@@ -9,8 +9,6 @@ describe('RegistrationErrorStep', () => {
const getRegistrationErrorStepWrapper = props => {
const wrapper = shallowWithIntl(
<RegistrationErrorStep
errorMsg={'error message'}
onSubmit={onSubmit}
{...props}
/>
);
......@@ -18,31 +16,90 @@ describe('RegistrationErrorStep', () => {
.dive(); // unwrap injectIntl()
};
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};
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();
});
......
......@@ -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);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment