From f284664818f65c811c8d080ade89474c4f4beda0 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sun, 29 Dec 2019 13:57:44 +0200 Subject: [PATCH] Cover change username page with e2e tests and fix minor bugs --- packages/app/components/accounts/actions.ts | 15 +++ packages/app/components/profile/Profile.tsx | 2 +- .../app/components/profile/ProfileField.js | 52 ----------- .../app/components/profile/ProfileField.tsx | 53 +++++++++++ .../app/components/profile/ProfileForm.tsx | 1 + .../PasswordRequestForm.js | 67 -------------- .../PasswordRequestForm.tsx | 54 +++++++++++ .../app/components/ui/popup/PopupStack.tsx | 2 +- packages/app/components/user/actions.ts | 30 +----- packages/app/components/user/factory.ts | 2 +- packages/app/components/user/reducer.ts | 2 +- packages/app/pages/profile/ProfilePage.tsx | 12 +-- .../profile/change-username.test.ts | 92 +++++++++++++++++++ tests-e2e/cypress/support/commands.js | 1 + tests-e2e/cypress/support/index.d.ts | 1 + 15 files changed, 230 insertions(+), 156 deletions(-) delete mode 100644 packages/app/components/profile/ProfileField.js create mode 100644 packages/app/components/profile/ProfileField.tsx delete mode 100644 packages/app/components/profile/passwordRequestForm/PasswordRequestForm.js create mode 100644 packages/app/components/profile/passwordRequestForm/PasswordRequestForm.tsx create mode 100644 tests-e2e/cypress/integration/profile/change-username.test.ts diff --git a/packages/app/components/accounts/actions.ts b/packages/app/components/accounts/actions.ts index 7f524a8..1c2c5d1 100644 --- a/packages/app/components/accounts/actions.ts +++ b/packages/app/components/accounts/actions.ts @@ -118,6 +118,21 @@ export function authenticate( }; } +/** + * Re-fetch user data for currently active account + */ +export function refreshUserData(): ThunkAction> { + 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); diff --git a/packages/app/components/profile/Profile.tsx b/packages/app/components/profile/Profile.tsx index 8cdc195..d2bbabe 100644 --- a/packages/app/components/profile/Profile.tsx +++ b/packages/app/components/profile/Profile.tsx @@ -19,7 +19,7 @@ type Props = { interfaceLocale: string; }; -class Profile extends React.Component { +class Profile extends React.PureComponent { UUID: HTMLElement | null; render() { diff --git a/packages/app/components/profile/ProfileField.js b/packages/app/components/profile/ProfileField.js deleted file mode 100644 index c0154c6..0000000 --- a/packages/app/components/profile/ProfileField.js +++ /dev/null @@ -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 => ; - } - - if (onChange) { - Action = props => ; - } - - return ( -
-
-
{label}
-
{value}
- - {Action ? ( - - - - ) : null} -
- - {warningMessage ? ( -
{warningMessage}
- ) : ( - '' - )} -
- ); - } -} diff --git a/packages/app/components/profile/ProfileField.tsx b/packages/app/components/profile/ProfileField.tsx new file mode 100644 index 0000000..ebe6a16 --- /dev/null +++ b/packages/app/components/profile/ProfileField.tsx @@ -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 => ; + } + + if (onChange) { + Action = props =>
; + } + + return ( +
+
+
{label}
+
{value}
+ + {Action && ( + + + + )} +
+ + {warningMessage && ( +
{warningMessage}
+ )} +
+ ); +} + +export default ProfileField; diff --git a/packages/app/components/profile/ProfileForm.tsx b/packages/app/components/profile/ProfileForm.tsx index cc99239..699d032 100644 --- a/packages/app/components/profile/ProfileForm.tsx +++ b/packages/app/components/profile/ProfileForm.tsx @@ -21,6 +21,7 @@ export class BackButton extends FormComponent<{ className={styles.backButton} to={to} title={this.formatMessage(messages.back)} + data-testid="back-to-profile" > diff --git a/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.js b/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.js deleted file mode 100644 index cc7cc1c..0000000 --- a/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.js +++ /dev/null @@ -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 ( -
-
-
-
-

- -

-
- -
- - -
- -
- - -
-
-
- ); - } - - onFormSubmit = () => { - this.props.onSubmit(this.props.form); - }; -} diff --git a/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.tsx b/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.tsx new file mode 100644 index 0000000..4ed9d5a --- /dev/null +++ b/packages/app/components/profile/passwordRequestForm/PasswordRequestForm.tsx @@ -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 ( +
+
+
onSubmit(form)} form={form}> +
+

+ +

+
+ +
+ + +
+ +
+ + +
+
+
+ ); +} + +export default PasswordRequestForm; diff --git a/packages/app/components/ui/popup/PopupStack.tsx b/packages/app/components/ui/popup/PopupStack.tsx index 754312e..21a2a22 100644 --- a/packages/app/components/ui/popup/PopupStack.tsx +++ b/packages/app/components/ui/popup/PopupStack.tsx @@ -35,7 +35,7 @@ export class PopupStack extends React.Component<{ return ( ) { } export const CHANGE_LANG = 'USER_CHANGE_LANG'; -export function changeLang(lang: string): ThunkAction> { - return (dispatch, getState) => - dispatch(setLocale(lang)).then((lang: string) => { +export function changeLang(targetLang: string): ThunkAction> { + 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> { }; } -export function fetchUserData(): ThunkAction> { - 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> { return (dispatch, getState) => { const { id } = getState().user; diff --git a/packages/app/components/user/factory.ts b/packages/app/components/user/factory.ts index 8ab6191..8a560e2 100644 --- a/packages/app/components/user/factory.ts +++ b/packages/app/components/user/factory.ts @@ -36,7 +36,7 @@ export function factory(store: Store): Promise { return; } - return Promise.reject(); + throw new Error('No active account found'); }) .catch(async () => { // the user is guest or user authentication failed diff --git a/packages/app/components/user/reducer.ts b/packages/app/components/user/reducer.ts index a791e2b..963d223 100644 --- a/packages/app/components/user/reducer.ts +++ b/packages/app/components/user/reducer.ts @@ -18,7 +18,7 @@ export interface User { } export type State = { - user: User; // TODO: replace with centralized global state + user: User; }; const defaults: User = { diff --git a/packages/app/pages/profile/ProfilePage.tsx b/packages/app/pages/profile/ProfilePage.tsx index 5af588b..ee39cdf 100644 --- a/packages/app/pages/profile/ProfilePage.tsx +++ b/packages/app/pages/profile/ProfilePage.tsx @@ -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; }) => Promise; - fetchUserData: () => Promise; + refreshUserData: () => Promise; } class ProfilePage extends React.Component { @@ -74,7 +74,7 @@ class ProfilePage extends React.Component { } 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 }, ); } diff --git a/tests-e2e/cypress/integration/profile/change-username.test.ts b/tests-e2e/cypress/integration/profile/change-username.test.ts new file mode 100644 index 0000000..08d05b4 --- /dev/null +++ b/tests-e2e/cypress/integration/profile/change-username.test.ts @@ -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', '/'); + }); +}); diff --git a/tests-e2e/cypress/support/commands.js b/tests-e2e/cypress/support/commands.js index 924a80c..8b21f7a 100644 --- a/tests-e2e/cypress/support/commands.js +++ b/tests-e2e/cypress/support/commands.js @@ -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, diff --git a/tests-e2e/cypress/support/index.d.ts b/tests-e2e/cypress/support/index.d.ts index 5bbb7ff..da7dd32 100644 --- a/tests-e2e/cypress/support/index.d.ts +++ b/tests-e2e/cypress/support/index.d.ts @@ -5,6 +5,7 @@ type AccountAlias = 'default' | 'default2'; interface Account { id: string; username: string; + password: string; email: string; token: string; refreshToken: string;