Cover change username page with e2e tests and fix minor bugs

This commit is contained in:
SleepWalker 2019-12-29 13:57:44 +02:00
parent b2c072e5e1
commit f284664818
15 changed files with 230 additions and 156 deletions

View File

@ -118,6 +118,21 @@ export function authenticate(
};
}
/**
* Re-fetch user data for currently active account
*/
export function refreshUserData(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!activeAccount) {
throw new Error('Can not fetch user data. No user.id available');
}
await dispatch(authenticate(activeAccount));
};
}
function findAccountIdFromToken(token: string): number {
const { sub, jti } = getJwtPayloads(token);

View File

@ -19,7 +19,7 @@ type Props = {
interfaceLocale: string;
};
class Profile extends React.Component<Props> {
class Profile extends React.PureComponent<Props> {
UUID: HTMLElement | null;
render() {

View File

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import styles from './profile.scss';
export default class ProfileField extends React.Component {
static propTypes = {
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element])
.isRequired,
link: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.element])
.isRequired,
warningMessage: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
};
render() {
const { label, value, warningMessage, link, onChange } = this.props;
let Action = null;
if (link) {
Action = props => <Link to={link} {...props} />;
}
if (onChange) {
Action = props => <a onClick={onChange} {...props} href="#" />;
}
return (
<div className={styles.paramItem}>
<div className={styles.paramRow}>
<div className={styles.paramName}>{label}</div>
<div className={styles.paramValue}>{value}</div>
{Action ? (
<Action to={link} className={styles.paramAction}>
<span className={styles.paramEditIcon} />
</Action>
) : null}
</div>
{warningMessage ? (
<div className={styles.paramMessage}>{warningMessage}</div>
) : (
''
)}
</div>
);
}
}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styles from './profile.scss';
function ProfileField({
label,
value,
warningMessage,
link,
onChange,
}: {
label: React.ReactNode;
link?: string;
onChange?: () => void;
value: React.ReactNode;
warningMessage?: React.ReactNode;
}) {
let Action: React.ElementType | null = null;
if (link) {
Action = props => <Link to={link} {...props} />;
}
if (onChange) {
Action = props => <a {...props} onClick={onChange} href="#" />;
}
return (
<div className={styles.paramItem} data-testid="profile-item">
<div className={styles.paramRow}>
<div className={styles.paramName}>{label}</div>
<div className={styles.paramValue}>{value}</div>
{Action && (
<Action
to={link}
className={styles.paramAction}
data-testid="profile-action"
>
<span className={styles.paramEditIcon} />
</Action>
)}
</div>
{warningMessage && (
<div className={styles.paramMessage}>{warningMessage}</div>
)}
</div>
);
}
export default ProfileField;

View File

@ -21,6 +21,7 @@ export class BackButton extends FormComponent<{
className={styles.backButton}
to={to}
title={this.formatMessage(messages.back)}
data-testid="back-to-profile"
>
<span className={styles.backIcon} />
<span className={styles.backText}>

View File

@ -1,67 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx';
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
import popupStyles from 'app/components/ui/popup/popup.scss';
import styles from './passwordRequestForm.scss';
import messages from './PasswordRequestForm.intl.json';
export default class PasswordRequestForm extends Component {
static displayName = 'PasswordRequestForm';
static propTypes = {
form: PropTypes.instanceOf(FormModel).isRequired,
onSubmit: PropTypes.func.isRequired,
};
render() {
const { form } = this.props;
return (
<div className={styles.requestPasswordForm}>
<div className={popupStyles.popup}>
<Form onSubmit={this.onFormSubmit} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} />
<div className={styles.description}>
<Message {...messages.description} />
</div>
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<Button
color="green"
label={messages.continue}
block
type="submit"
/>
</Form>
</div>
</div>
);
}
onFormSubmit = () => {
this.props.onSubmit(this.props.form);
};
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import clsx from 'clsx';
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
import popupStyles from 'app/components/ui/popup/popup.scss';
import styles from './passwordRequestForm.scss';
import messages from './PasswordRequestForm.intl.json';
function PasswordRequestForm({
form,
onSubmit,
}: {
form: FormModel;
onSubmit: (form: FormModel) => void;
}) {
return (
<div
className={styles.requestPasswordForm}
data-testid="password-request-form"
>
<div className={popupStyles.popup}>
<Form onSubmit={() => onSubmit(form)} form={form}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
</div>
<div className={clsx(popupStyles.body, styles.body)}>
<span className={styles.lockIcon} />
<div className={styles.description}>
<Message {...messages.description} />
</div>
<Input
{...form.bindField('password')}
type="password"
required
autoFocus
color="green"
skin="light"
center
/>
</div>
<Button color="green" label={messages.continue} block type="submit" />
</Form>
</div>
</div>
);
}
export default PasswordRequestForm;

View File

@ -35,7 +35,7 @@ export class PopupStack extends React.Component<{
return (
<CSSTransition
key={index}
clsx={{
classNames={{
enter: styles.trEnter,
enterActive: styles.trEnterActive,
exit: styles.trExit,

View File

@ -1,8 +1,6 @@
import {
getInfo as getInfoEndpoint,
changeLang as changeLangEndpoint,
acceptRules as acceptRulesEndpoint,
UserResponse,
} from 'app/services/api/accounts';
import { setLocale } from 'app/components/i18n/actions';
import { ThunkAction } from 'app/reducers';
@ -39,9 +37,9 @@ export function setUser(payload: Partial<User>) {
}
export const CHANGE_LANG = 'USER_CHANGE_LANG';
export function changeLang(lang: string): ThunkAction<Promise<void>> {
return (dispatch, getState) =>
dispatch(setLocale(lang)).then((lang: string) => {
export function changeLang(targetLang: string): ThunkAction<Promise<void>> {
return async (dispatch, getState) =>
dispatch(setLocale(targetLang)).then((lang: string) => {
const { id, isGuest, lang: oldLang } = getState().user;
if (oldLang === lang) {
@ -72,28 +70,6 @@ export function setGuest(): ThunkAction<Promise<void>> {
};
}
export function fetchUserData(): ThunkAction<Promise<UserResponse>> {
return async (dispatch, getState) => {
const { id } = getState().user;
if (!id) {
throw new Error('Can not fetch user data. No user.id available');
}
const resp = await getInfoEndpoint(id);
dispatch(
updateUser({
isGuest: false,
...resp,
}),
);
dispatch(changeLang(resp.lang));
return resp;
};
}
export function acceptRules(): ThunkAction<Promise<{ success: boolean }>> {
return (dispatch, getState) => {
const { id } = getState().user;

View File

@ -36,7 +36,7 @@ export function factory(store: Store): Promise<void> {
return;
}
return Promise.reject();
throw new Error('No active account found');
})
.catch(async () => {
// the user is guest or user authentication failed

View File

@ -18,7 +18,7 @@ export interface User {
}
export type State = {
user: User; // TODO: replace with centralized global state
user: User;
};
const defaults: User = {

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import { fetchUserData } from 'app/components/user/actions';
import { refreshUserData } from 'app/components/accounts/actions';
import { create as createPopup } from 'app/components/ui/popup/actions';
import PasswordRequestForm from 'app/components/profile/passwordRequestForm/PasswordRequestForm';
import logger from 'app/services/logger';
@ -24,7 +24,7 @@ interface Props {
form: FormModel;
sendData: () => Promise<any>;
}) => Promise<void>;
fetchUserData: () => Promise<any>;
refreshUserData: () => Promise<any>;
}
class ProfilePage extends React.Component<Props> {
@ -74,7 +74,7 @@ class ProfilePage extends React.Component<Props> {
}
goToProfile = async () => {
await this.props.fetchUserData();
await this.props.refreshUserData();
browserHistory.push('/');
};
@ -85,7 +85,7 @@ export default connect(
userId: state.user.id,
}),
{
fetchUserData,
refreshUserData,
onSubmit: ({
form,
sendData,
@ -158,11 +158,11 @@ export default connect(
).length > 0;
if (parentFormHasErrors) {
// something wrong with parent form, hidding popup and show that form
// something wrong with parent form, hiding popup and show that form
props.onClose();
reject(resp);
logger.warn(
'Profile: can not submit pasword popup due to errors in source form',
'Profile: can not submit password popup due to errors in source form',
{ resp },
);
}

View File

@ -0,0 +1,92 @@
describe('Change username', () => {
it('should change username', () => {
cy.server();
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
cy.route({
method: 'GET',
url: `/api/v1/accounts/${account.id}`,
response: {
id: 7,
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
username: 'FooBar',
isOtpEnabled: false,
registeredAt: 1475568334,
lang: 'en',
elyProfileLink: 'http://ely.by/u7',
email: 'danilenkos@auroraglobal.com',
isActive: true,
passwordChangedAt: 1476075696,
hasMojangUsernameCollision: true,
shouldAcceptRules: false,
},
});
cy.route({
method: 'POST',
url: `/api/v1/accounts/${account.id}/username`,
}).as('username');
cy.visit('/');
cy.getByTestId('profile-item')
.contains('Nickname')
.closest('[data-testid="profile-item"]')
.getByTestId('profile-action')
.click();
cy.location('pathname').should('eq', '/profile/change-username');
cy.get('[name=username]').type(`{selectall}${account.username}{enter}`);
cy.wait('@username')
.its('requestBody')
.should(
'eq',
new URLSearchParams({
username: account.username,
password: '',
}).toString(),
);
cy.getByTestId('password-request-form').should('be.visible');
// unmock accounts route
cy.route({
method: 'GET',
url: `/api/v1/accounts/${account.id}`,
});
cy.get('[name=password]').type(account.password);
cy.getByTestId('password-request-form')
.find('[type=submit]')
.click();
cy.wait('@username')
.its('requestBody')
.should(
'eq',
new URLSearchParams({
username: account.username,
password: account.password,
}).toString(),
);
cy.location('pathname').should('eq', '/');
cy.getByTestId('profile-item').should('contain', account.username);
cy.getByTestId('toolbar')
.contains(account.username)
.click();
cy.getByTestId('active-account').should('contain', account.username);
});
});
it('should go back to profile', () => {
cy.login({ accounts: ['default'] });
cy.visit('/profile/change-username');
cy.getByTestId('back-to-profile').click();
cy.location('pathname').should('eq', '/');
});
});

View File

@ -65,6 +65,7 @@ Cypress.Commands.add(
return {
id: credentials.id,
username: credentials.username,
password: credentials.password,
email: credentials.email,
token: resp.access_token,
refreshToken: resp.refresh_token,

View File

@ -5,6 +5,7 @@ type AccountAlias = 'default' | 'default2';
interface Account {
id: string;
username: string;
password: string;
email: string;
token: string;
refreshToken: string;