From 30f9909b45a3f639a35c748185a3582269600470 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 20 Apr 2017 13:56:12 +0300 Subject: [PATCH] Refactor authentication from redux-saga to redux-thunk --- src/app.test.js | 4 +- .../AuthenticationPage/AuthenticationPage.js | 42 ++- .../AuthenticationPage.test.js | 4 + .../AuthenticationPage.test.js.snap | 3 +- src/containers/LoginForm/LoginForm.js | 39 ++- .../__snapshots__/LoginForm.test.js.snap | 4 +- src/containers/Topbar/Topbar.js | 53 ++- src/ducks/Auth.duck.js | 106 +++--- src/ducks/Auth.test.js | 315 ++++++++---------- src/sagas.js | 3 +- src/translations/en.json | 15 +- 11 files changed, 305 insertions(+), 283 deletions(-) diff --git a/src/app.test.js b/src/app.test.js index 425693a9..2e2d1518 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -41,8 +41,8 @@ describe('Application', () => { '/s/map': 'Search page: map', '/l/listing-title-slug/1234': 'Loading listing data', '/u/1234': 'Profile page with display name: 1234', - '/login': 'Authentication page: login tab', - '/signup': 'Authentication page: signup tab', + '/login': 'Log in', + '/signup': 'Signup', '/password': 'Request new password', '/password/forgotten': 'Request new password', '/password/change': 'Type new password', diff --git a/src/containers/AuthenticationPage/AuthenticationPage.js b/src/containers/AuthenticationPage/AuthenticationPage.js index 64a9de8e..f2e99c4a 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.js @@ -1,10 +1,11 @@ import React, { PropTypes } from 'react'; +import { compose } from 'redux'; import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { PageLayout, NamedRedirect } from '../../components'; import { LoginForm, SignUpForm } from '../../containers'; -import { login } from '../../ducks/Auth.duck'; +import { login, loginOrLogoutInProgress } from '../../ducks/Auth.duck'; import css from './AuthenticationPage.css'; @@ -14,24 +15,33 @@ export const AuthenticationPageComponent = props => { tab, isAuthenticated, loginError, + authInProgress, onLoginSubmit, onSignUpSubmit, + intl, } = props; const isLogin = tab === 'login'; const from = location.state && location.state.from ? location.state.from : null; const authRedirect = from ? : ; - // TODO: use FlashMessages for auth errors - /* eslint-disable no-console */ if (loginError && console && console.error) { + // TODO: use FlashMessages for auth errors console.error(loginError); } /* eslint-enable no-console */ + const loginTitle = intl.formatMessage({ + id: 'AuthenticationPage.loginPageTitle', + }); + const signupTitle = intl.formatMessage({ + id: 'AuthenticationPage.signupPageTitle', + }); + const title = isLogin ? loginTitle : signupTitle; + return ( - +
{isAuthenticated ? authRedirect : null} {loginError @@ -46,7 +56,7 @@ export const AuthenticationPageComponent = props => {

: null} {isLogin - ? + ? : } {/* {isLogin ? Sign up @@ -70,21 +80,29 @@ AuthenticationPageComponent.propTypes = { tab: oneOf(['login', 'signup']), isAuthenticated: bool.isRequired, loginError: instanceOf(Error), + authInProgress: bool.isRequired, onLoginSubmit: func.isRequired, onSignUpSubmit: func.isRequired, + intl: intlShape.isRequired, }; -const mapStateToProps = state => ({ - isAuthenticated: state.Auth.isAuthenticated, - loginError: state.Auth.loginError, -}); +const mapStateToProps = state => { + const { isAuthenticated, loginError } = state.Auth; + return { + isAuthenticated, + loginError, + authInProgress: loginOrLogoutInProgress(state), + }; +}; const mapDispatchToProps = dispatch => ({ onLoginSubmit: ({ email, password }) => dispatch(login(email, password)), - onSignUpSubmit: ({ email, password }) => dispatch(login(email, password)), + onSignUpSubmit: () => { + console.log('signup submit'); // eslint-disable-line + }, }); -const AuthenticationPage = connect(mapStateToProps, mapDispatchToProps)( +const AuthenticationPage = compose(connect(mapStateToProps, mapDispatchToProps), injectIntl)( AuthenticationPageComponent ); diff --git a/src/containers/AuthenticationPage/AuthenticationPage.test.js b/src/containers/AuthenticationPage/AuthenticationPage.test.js index 9173acca..38261e81 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.test.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.test.js @@ -1,14 +1,18 @@ import React from 'react'; import { renderShallow } from '../../util/test-helpers'; +import { fakeIntl } from '../../util/test-data'; import { AuthenticationPageComponent } from './AuthenticationPage'; describe('AuthenticationPageComponent', () => { it('matches snapshot', () => { const props = { + tab: 'login', location: { state: { from: '/protected' } }, isAuthenticated: false, + authInProgress: false, onLoginSubmit: () => null, onSignUpSubmit: () => null, + intl: fakeIntl, }; const tree = renderShallow(); expect(tree).toMatchSnapshot(); diff --git a/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap b/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap index 4ebdd09b..43a46c62 100644 --- a/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap +++ b/src/containers/AuthenticationPage/__snapshots__/AuthenticationPage.test.js.snap @@ -1,6 +1,6 @@ exports[`AuthenticationPageComponent matches snapshot 1`] = ` + title="AuthenticationPage.loginPageTitle">

diff --git a/src/containers/LoginForm/LoginForm.js b/src/containers/LoginForm/LoginForm.js index 4b1253d8..e5ce20df 100644 --- a/src/containers/LoginForm/LoginForm.js +++ b/src/containers/LoginForm/LoginForm.js @@ -1,26 +1,49 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; +import { compose } from 'redux'; +import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import { reduxForm, propTypes as formPropTypes } from 'redux-form'; import { Button, LabeledField } from '../../components'; import css from './LoginForm.css'; -const LoginForm = props => { - const { handleSubmit, pristine, submitting } = props; +const { bool } = PropTypes; + +const LoginFormComponent = props => { + const { handleSubmit, pristine, submitting, inProgress, intl } = props; + const emailLabel = intl.formatMessage({ + id: 'LoginForm.emailLabel', + }); + const passwordLabel = intl.formatMessage({ + id: 'LoginForm.passwordLabel', + }); + const submitDisabled = pristine || submitting || inProgress; return (
- +
- +
- +
); }; -LoginForm.propTypes = { ...formPropTypes }; +LoginFormComponent.defaultProps = { inProgress: false }; -export default reduxForm({ form: 'login' })(LoginForm); +LoginFormComponent.propTypes = { + ...formPropTypes, + inProgress: bool, + intl: intlShape.isRequired, +}; + +const formName = 'LoginForm'; + +const LoginForm = compose(reduxForm({ form: formName }), injectIntl)(LoginFormComponent); + +export default LoginForm; diff --git a/src/containers/LoginForm/__snapshots__/LoginForm.test.js.snap b/src/containers/LoginForm/__snapshots__/LoginForm.test.js.snap index 98495067..512e3c5d 100644 --- a/src/containers/LoginForm/__snapshots__/LoginForm.test.js.snap +++ b/src/containers/LoginForm/__snapshots__/LoginForm.test.js.snap @@ -48,7 +48,9 @@ exports[`LoginForm matches snapshot 1`] = ` className="" disabled={true} type="submit"> - Log in + + Log in + `; diff --git a/src/containers/Topbar/Topbar.js b/src/containers/Topbar/Topbar.js index f654a88e..2ef4f186 100644 --- a/src/containers/Topbar/Topbar.js +++ b/src/containers/Topbar/Topbar.js @@ -1,8 +1,12 @@ import React, { PropTypes } from 'react'; +import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import { logout } from '../../ducks/Auth.duck'; +import { logout, loginOrLogoutInProgress } from '../../ducks/Auth.duck'; import { NamedLink, Button } from '../../components'; +import { withFlattenedRoutes } from '../../util/contextHelpers'; +import { pathByRouteName } from '../../util/routes'; +import * as propTypes from '../../util/propTypes'; import css from './Topbar.css'; @@ -10,15 +14,22 @@ import css from './Topbar.css'; const House = () => ; /* eslint-enable react/no-danger */ -const Topbar = props => { - const { isAuthenticated, onLogout, history } = props; +const TopbarComponent = props => { + const { isAuthenticated, authInProgress, onLogout, history, flattenedRoutes } = props; const handleLogout = () => { - // History push function is passed to the action to enable - // redirect to home when logout succeeds. - onLogout(history.push); + const path = pathByRouteName('LandingPage', flattenedRoutes); + history.push(path); + onLogout().then(() => { + // TODO: show flash message + console.log('logged out'); // eslint-disable-line + }); }; + const authAction = isAuthenticated + ? + : Login; + return (
@@ -27,30 +38,44 @@ const Topbar = props => {
- {isAuthenticated - ? - : Login} + {authInProgress ? null : authAction}
); }; -Topbar.defaultProps = { user: null }; +TopbarComponent.defaultProps = { user: null }; -const { bool, func, shape } = PropTypes; +const { bool, func, shape, arrayOf } = PropTypes; -Topbar.propTypes = { +TopbarComponent.propTypes = { isAuthenticated: bool.isRequired, + authInProgress: bool.isRequired, onLogout: func.isRequired, // from withRouter history: shape({ push: func.isRequired, }).isRequired, + + // from withFlattenedRoutes + flattenedRoutes: arrayOf(propTypes.route), }; -const mapStateToProps = state => ({ isAuthenticated: state.Auth.isAuthenticated }); +const mapStateToProps = state => { + const { isAuthenticated } = state.Auth; + return { + isAuthenticated, + authInProgress: loginOrLogoutInProgress(state), + }; +}; const mapDispatchToProps = dispatch => ({ onLogout: historyPush => dispatch(logout(historyPush)) }); -export default connect(mapStateToProps, mapDispatchToProps)(withRouter(Topbar)); +const Topbar = compose( + connect(mapStateToProps, mapDispatchToProps), + withRouter, + withFlattenedRoutes +)(TopbarComponent); + +export default Topbar; diff --git a/src/ducks/Auth.duck.js b/src/ducks/Auth.duck.js index 5187bdc8..ef144e6c 100644 --- a/src/ducks/Auth.duck.js +++ b/src/ducks/Auth.duck.js @@ -1,5 +1,3 @@ -import { call, put, take, cancel, fork } from 'redux-saga/effects'; - const authenticated = authInfo => authInfo.grantType === 'refresh_token'; // ================ Action types ================ // @@ -23,7 +21,9 @@ const initialState = { isAuthenticated: false, authInfoError: null, loginError: null, + loginInProgress: false, logoutError: null, + logoutInProgress: false, }; export default function reducer(state = initialState, action = {}) { @@ -38,89 +38,44 @@ export default function reducer(state = initialState, action = {}) { return { ...state, authInfoLoaded: true, authInfoError: payload }; case LOGIN_REQUEST: - return { ...state, loginError: null, logoutError: null }; + return { ...state, loginInProgress: true, loginError: null, logoutError: null }; case LOGIN_SUCCESS: - return { ...state, isAuthenticated: true }; + return { ...state, loginInProgress: false, isAuthenticated: true }; case LOGIN_ERROR: - return { ...state, loginError: payload }; + return { ...state, loginInProgress: false, loginError: payload }; case LOGOUT_REQUEST: - return { ...state, loginError: null, logoutError: null }; + return { ...state, logoutInProgress: true, loginError: null, logoutError: null }; case LOGOUT_SUCCESS: - return { ...state, isAuthenticated: false }; + return { ...state, logoutInProgress: false, isAuthenticated: false }; case LOGOUT_ERROR: - return { ...state, logoutError: payload }; + return { ...state, logoutInProgress: false, logoutError: payload }; default: return state; } } +// ================ Selectors ================ // + +export const loginOrLogoutInProgress = state => { + const { loginInProgress, logoutInProgress } = state.Auth; + return loginInProgress || logoutInProgress; +}; + // ================ Action creators ================ // export const authInfoRequest = () => ({ type: AUTH_INFO_REQUEST }); export const authInfoSuccess = info => ({ type: AUTH_INFO_SUCCESS, payload: info }); export const authInfoError = error => ({ type: AUTH_INFO_ERROR, payload: error, error: true }); -export const login = (username, password) => ({ - type: LOGIN_REQUEST, - payload: { username, password }, -}); +export const loginRequest = () => ({ type: LOGIN_REQUEST }); export const loginSuccess = () => ({ type: LOGIN_SUCCESS }); export const loginError = error => ({ type: LOGIN_ERROR, payload: error, error: true }); -export const logout = historyPush => ({ type: LOGOUT_REQUEST, payload: { historyPush } }); +export const logoutRequest = () => ({ type: LOGOUT_REQUEST }); export const logoutSuccess = () => ({ type: LOGOUT_SUCCESS }); export const logoutError = error => ({ type: LOGOUT_ERROR, payload: error, error: true }); -// ================ Worker sagas ================ // - -export function* callLogin(action, sdk) { - const { payload } = action; - const { username, password } = payload; - try { - yield call(sdk.login, { username, password }); - yield put(loginSuccess()); - } catch (e) { - yield put(loginError(e)); - } -} - -export function* callLogout(action, sdk) { - const { payload } = action; - const { historyPush } = payload; - try { - yield call(sdk.logout); - yield put(logoutSuccess()); - yield call(historyPush, '/'); - } catch (e) { - yield put(logoutError(e)); - } -} - -// ================ Watcher sagas ================ // - -export function* watchAuth(sdk) { - let task; - - while (true) { - // Take either login or logout action - const action = yield take([LOGIN_REQUEST, LOGOUT_REQUEST]); - - // Previous task should be cancelled if a new login or logout - // action is received - if (task) { - yield cancel(task); - } - - // Fork the correct worker and continue waiting for actions - if (action.type === LOGIN_REQUEST) { - task = yield fork(callLogin, action, sdk); - } else if (action.type === LOGOUT_REQUEST) { - task = yield fork(callLogout, action, sdk); - } - } -} - // ================ Thunks ================ // export const authInfo = () => @@ -131,3 +86,30 @@ export const authInfo = () => .then(info => dispatch(authInfoSuccess(info))) .catch(e => dispatch(authInfoError(e))); }; + +export const login = (username, password) => + (dispatch, getState, sdk) => { + if (loginOrLogoutInProgress(getState())) { + return Promise.reject(new Error('Login or logout already in progress')); + } + dispatch(loginRequest()); + + // Note that the thunk does not reject when the login fails, it + // just dispatches the login error action. + return sdk + .login({ username, password }) + .then(() => dispatch(loginSuccess())) + .catch(e => dispatch(loginError(e))); + }; + +export const logout = () => + (dispatch, getState, sdk) => { + if (loginOrLogoutInProgress(getState())) { + return Promise.reject(new Error('Login or logout already in progress')); + } + dispatch(logoutRequest()); + + // Not that the thunk does not reject when the logout fails, it + // just dispatches the logout error action. + return sdk.logout().then(() => dispatch(logoutSuccess())).catch(e => dispatch(logoutError(e))); + }; diff --git a/src/ducks/Auth.test.js b/src/ducks/Auth.test.js index 942e3cae..17b77e49 100644 --- a/src/ducks/Auth.test.js +++ b/src/ducks/Auth.test.js @@ -10,10 +10,12 @@ import reducer, { authInfoSuccess, authInfoError, login, + loginRequest, loginSuccess, loginError, callLogin, logout, + logoutRequest, logoutSuccess, logoutError, callLogout, @@ -29,32 +31,35 @@ describe('Auth duck', () => { expect(state.authInfoError).toBeNull(); expect(state.loginError).toBeNull(); expect(state.logoutError).toBeNull(); + expect(state.loginInProgress).toEqual(false); + expect(state.logoutInProgress).toEqual(false); }); it('should login successfully', () => { - const username = 'x@x.x'; - const password = 'pass'; + const initialState = reducer(); + const loginRequestState = reducer(initialState, loginRequest()); + expect(loginRequestState.isAuthenticated).toEqual(false); + expect(loginRequestState.loginError).toBeNull(); + expect(loginRequestState.loginInProgress).toEqual(true); - let state = reducer(); - state = reducer(state, login(username, password)); - expect(state.isAuthenticated).toEqual(false); - expect(state.loginError).toBeNull(); - - state = reducer(state, loginSuccess()); - expect(state.isAuthenticated).toEqual(true); - expect(state.loginError).toBeNull(); + const loginSuccessState = reducer(loginRequestState, loginSuccess()); + expect(loginSuccessState.isAuthenticated).toEqual(true); + expect(loginSuccessState.loginError).toBeNull(); + expect(loginSuccessState.loginInProgress).toEqual(false); }); it('should handle failed login', () => { - let state = reducer(); - state = reducer(state, login('username', 'pass')); - expect(state.isAuthenticated).toEqual(false); - expect(state.loginError).toBeNull(); + let initialState = reducer(); + const loginRequestState = reducer(initialState, loginRequest()); + expect(loginRequestState.isAuthenticated).toEqual(false); + expect(loginRequestState.loginError).toBeNull(); + expect(loginRequestState.loginInProgress).toEqual(true); const error = new Error('test error'); - state = reducer(state, loginError(error)); - expect(state.isAuthenticated).toEqual(false); - expect(state.loginError).toEqual(error); + const loginErrorState = reducer(loginRequestState, loginError(error)); + expect(loginErrorState.isAuthenticated).toEqual(false); + expect(loginErrorState.loginError).toEqual(error); + expect(loginErrorState.loginInProgress).toEqual(false); }); it('should set initial state for unauthenticated users', () => { @@ -88,177 +93,135 @@ describe('Auth duck', () => { }); }); - describe('login worker', () => { - it('should succeed when API call fulfills', () => { - const username = 'username'; + describe('login thunk', () => { + it('should dispatch success', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const getState = () => ({ Auth: initialState }); + const sdk = { login: jest.fn(() => Promise.resolve({})) }; + const username = 'x.x@example.com'; const password = 'pass'; - const payload = { username, password }; - const sdk = { login: jest.fn() }; - const loginAction = login(username, password); - const worker = callLogin(loginAction, sdk); - expect(worker.next()).toEqual({ - done: false, - value: call(sdk.login, { username, password }), - }); - expect(worker.next(payload)).toEqual({ done: false, value: put(loginSuccess()) }); - expect(worker.next().done).toEqual(true); - expect(sdk.login).not.toHaveBeenCalled(); - }); - it('should fail when API call rejects', () => { - const username = 'username'; - const password = 'pass'; - const sdk = { login: jest.fn() }; - const loginAction = login(username, password); - const worker = callLogin(loginAction, sdk); - expect(worker.next()).toEqual({ - done: false, - value: call(sdk.login, { username, password }), + return login(username, password)(dispatch, getState, sdk).then(() => { + expect(sdk.login.mock.calls).toEqual([[{ username, password }]]); + expect(dispatch.mock.calls).toEqual([[loginRequest()], [loginSuccess()]]); }); - const error = new Error('Test login failed'); - expect(worker.throw(error)).toEqual({ done: false, value: put(loginError(error)) }); - expect(worker.next().done).toEqual(true); - expect(sdk.login).not.toHaveBeenCalled(); + }); + it('should dispatch error', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const getState = () => ({ Auth: initialState }); + const error = new Error('could not login'); + const sdk = { login: jest.fn(() => Promise.reject(error)) }; + const username = 'x.x@example.com'; + const password = 'pass'; + + return login(username, password)(dispatch, getState, sdk).then(() => { + expect(sdk.login.mock.calls).toEqual([[{ username, password }]]); + expect(dispatch.mock.calls).toEqual([[loginRequest()], [loginError(error)]]); + }); + }); + it('should reject if another login is in progress', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const loginInProgressState = reducer(initialState, loginRequest()); + const getState = () => ({ Auth: loginInProgressState }); + const sdk = { login: jest.fn(() => Promise.resolve({})) }; + const username = 'x.x@example.com'; + const password = 'pass'; + + return login(username, password)(dispatch, getState, sdk).then( + () => { + throw new Error('should not succeed'); + }, + e => { + expect(e.message).toEqual('Login or logout already in progress'); + expect(sdk.login.mock.calls.length).toEqual(0); + expect(dispatch.mock.calls.length).toEqual(0); + } + ); + }); + it('should reject if logout is in progress', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const logoutInProgressState = reducer(initialState, logoutRequest()); + const getState = () => ({ Auth: logoutInProgressState }); + const sdk = { login: jest.fn(() => Promise.resolve({})) }; + const username = 'x.x@example.com'; + const password = 'pass'; + + return login(username, password)(dispatch, getState, sdk).then( + () => { + throw new Error('should not succeed'); + }, + e => { + expect(e.message).toEqual('Login or logout already in progress'); + expect(sdk.login.mock.calls.length).toEqual(0); + expect(dispatch.mock.calls.length).toEqual(0); + } + ); }); }); - describe('logout worker', () => { - it('should redirect to root after logout', () => { - const sdk = { logout: jest.fn() }; - const historyPush = jest.fn(); - const action = logout(historyPush); - const worker = callLogout(action, sdk); - expect(worker.next()).toEqual({ done: false, value: call(sdk.logout) }); - expect(worker.next()).toEqual({ done: false, value: put(logoutSuccess()) }); - expect(worker.next()).toEqual({ done: false, value: call(historyPush, '/') }); - expect(worker.next().done).toEqual(true); - expect(sdk.logout).not.toHaveBeenCalled(); - expect(historyPush).not.toHaveBeenCalled(); + describe('logout thunk', () => { + it('should dispatch success', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const getState = () => ({ Auth: initialState }); + const sdk = { logout: jest.fn(() => Promise.resolve({})) }; + + return logout()(dispatch, getState, sdk).then(() => { + expect(sdk.logout.mock.calls.length).toEqual(1); + expect(dispatch.mock.calls).toEqual([[logoutRequest()], [logoutSuccess()]]); + }); }); + it('should dispatch error', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const getState = () => ({ Auth: initialState }); + const error = new Error('could not logout'); + const sdk = { logout: jest.fn(() => Promise.reject(error)) }; - it('should not redirect if logout fails', () => { - const sdk = { logout: jest.fn() }; - const historyPush = jest.fn(); - const action = logout(historyPush); - const worker = callLogout(action, sdk); - expect(worker.next()).toEqual({ done: false, value: call(sdk.logout) }); - const error = new Error('Test logout error'); - expect(worker.throw(error)).toEqual({ done: false, value: put(logoutError(error)) }); - expect(worker.next().done).toEqual(true); - expect(sdk.logout).not.toHaveBeenCalled(); - expect(historyPush).not.toHaveBeenCalled(); + return logout()(dispatch, getState, sdk).then(() => { + expect(sdk.logout.mock.calls.length).toEqual(1); + expect(dispatch.mock.calls).toEqual([[logoutRequest()], [logoutError(error)]]); + }); }); - }); + it('should reject if another logout is in progress', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const logoutInProgressState = reducer(initialState, logoutRequest()); + const getState = () => ({ Auth: logoutInProgressState }); + const sdk = { logout: jest.fn(() => Promise.resolve({})) }; - describe('auth watcher', () => { - it('calls login', () => { - const sdk = { login: jest.fn() }; - const watcher = watchAuth(sdk); - const loginAction = login('username', 'password'); - const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]); - const forkLogin = fork(callLogin, loginAction, sdk); - - // The watcher should first take a login or a logout action - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - // If we pass it a login action, it should fork the login worker - expect(watcher.next(loginAction).value).toEqual(forkLogin); - - // It should continue back at taking a login or a logout action - expect(watcher.next({}).value).toEqual(takeLoginOrLogout); - - expect(sdk.login).not.toHaveBeenCalled(); + return logout()(dispatch, getState, sdk).then( + () => { + throw new Error('should not succeed'); + }, + e => { + expect(e.message).toEqual('Login or logout already in progress'); + expect(sdk.logout.mock.calls.length).toEqual(0); + expect(dispatch.mock.calls.length).toEqual(0); + } + ); }); + it('should reject if login is in progress', () => { + const dispatch = jest.fn(); + const initialState = reducer(); + const loginInProgressState = reducer(initialState, loginRequest()); + const getState = () => ({ Auth: loginInProgressState }); + const sdk = { logout: jest.fn(() => Promise.resolve({})) }; - it('calls logout', () => { - const sdk = { logout: jest.fn() }; - const historyPush = jest.fn(); - const watcher = watchAuth(sdk); - const logoutAction = logout(historyPush); - const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]); - const forkLogout = fork(callLogout, logoutAction, sdk); - - // The watcher should first take a login or a logout action - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - // If we pass it a logout action, it should fork the logout worker - expect(watcher.next(logoutAction).value).toEqual(forkLogout); - - // It should continue back at taking a login or a logout action - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - expect(sdk.logout).not.toHaveBeenCalled(); - expect(historyPush).not.toHaveBeenCalled(); - }); - - it('should cancel login if another login comes', () => { - const sdk = { login: jest.fn(), logout: jest.fn() }; - const watcher = watchAuth(sdk); - const loginAction1 = login('username1', 'password1'); - const loginAction2 = login('username2', 'password2'); - const task = createMockTask(); - const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]); - const forkLogin1 = fork(callLogin, loginAction1, sdk); - const forkLogin2 = fork(callLogin, loginAction2, sdk); - const cancelLogin1 = cancel(task); - - // First take a login or a logout - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - // Passing the first login should fork login worker - expect(watcher.next(loginAction1).value).toEqual(forkLogin1); - - // Passing in the task to mock the fork results should make the - // watcher take a login or a logout again - expect(watcher.next(task).value).toEqual(takeLoginOrLogout); - - // Passing in another login should cancel the first fork task - expect(watcher.next(loginAction2).value).toEqual(cancelLogin1); - - // Then it should fork the second login - expect(watcher.next().value).toEqual(forkLogin2); - - // And finally take a login or a logout again - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - expect(sdk.login).not.toHaveBeenCalled(); - expect(sdk.logout).not.toHaveBeenCalled(); - }); - - it('should cancel login if a logout comes', () => { - const sdk = { login: jest.fn(), logout: jest.fn() }; - const historyPush = jest.fn(); - const watcher = watchAuth(sdk); - const loginAction = login('username', 'password'); - const logoutAction = logout(historyPush); - const task = createMockTask(); - const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]); - const forkLogin = fork(callLogin, loginAction, sdk); - const forkLogout = fork(callLogout, logoutAction, sdk); - const cancelLogin = cancel(task); - - // First take a login or a logout - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - // Passing the login should fork login worker - expect(watcher.next(loginAction).value).toEqual(forkLogin); - - // Passing in the task to mock the fork results should make the - // watcher take a login or a logout again - expect(watcher.next(task).value).toEqual(takeLoginOrLogout); - - // Passing in logout should cancel the login fork task - expect(watcher.next(logoutAction).value).toEqual(cancelLogin); - - // Then it should fork the logout - expect(watcher.next().value).toEqual(forkLogout); - - // And finally take a login or a logout again - expect(watcher.next().value).toEqual(takeLoginOrLogout); - - expect(sdk.login).not.toHaveBeenCalled(); - expect(sdk.logout).not.toHaveBeenCalled(); - expect(historyPush).not.toHaveBeenCalled(); + return logout()(dispatch, getState, sdk).then( + () => { + throw new Error('should not succeed'); + }, + e => { + expect(e.message).toEqual('Login or logout already in progress'); + expect(sdk.logout.mock.calls.length).toEqual(0); + expect(dispatch.mock.calls.length).toEqual(0); + } + ); }); }); }); diff --git a/src/sagas.js b/src/sagas.js index d81e7395..6af10ab2 100644 --- a/src/sagas.js +++ b/src/sagas.js @@ -1,9 +1,8 @@ -import { watchAuth } from './ducks/Auth.duck'; import { watchSdk } from './ducks/sdk.duck'; const createRootSaga = sdk => function* rootSaga() { - yield [watchAuth(sdk), watchSdk(sdk)]; + yield [watchSdk(sdk)]; }; export default createRootSaga; diff --git a/src/translations/en.json b/src/translations/en.json index da4c7fec..0c0b2655 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1,6 +1,8 @@ { "AuthenticationPage.loginFailed": "The email and password you entered did not match our records. Please double-check and try again.", + "AuthenticationPage.loginPageTitle": "Log in", "AuthenticationPage.loginRequiredFor": "You must log in to view the page at", + "AuthenticationPage.signupPageTitle": "Signup", "AuthorInfo.host": "Hosted by {authorName}", "BookingDatesForm.bookingEndTitle": "End date", "BookingDatesForm.bookingStartTitle": "Start date", @@ -55,19 +57,22 @@ "ListingPage.editListing": "Edit listing", "ListingPage.loadingListingData": "Loading listing data", "ListingPage.noListingData": "Could not find listing data", + "LoginForm.emailLabel": "Email", + "LoginForm.logIn": "Log in", + "LoginForm.passwordLabel": "Password", "ModalInMobile.closeModal": "Close modal", - "OrderDetailsPanel.orderAcceptedTitle": "You have booked {title}.", - "OrderDetailsPanel.orderRejectedTitle": "You requested to book {title}.", - "OrderDetailsPanel.orderRequestedTitle": "You have requested to book {title}.", "OrderDetailsPanel.orderAcceptedStatus": "{providerName} accepted the booking.", + "OrderDetailsPanel.orderAcceptedTitle": "You have booked {title}.", "OrderDetailsPanel.orderRejectedStatus": "{providerName} rejected the booking.", + "OrderDetailsPanel.orderRejectedTitle": "You requested to book {title}.", "OrderDetailsPanel.orderRequestedStatus": "{providerName} has been notified about the booking request, so sit back and relax.", + "OrderDetailsPanel.orderRequestedTitle": "You have requested to book {title}.", "OrderPage.loadingData": "Loading order data.", "OrderPage.title": "Order details for ${title}.", "PageLayout.authInfoFailed": "Could not get authentication information.", "PageLayout.logoutFailed": "Logout failed. Please try again.", - "PaginationLinks.previous": "Previous page", "PaginationLinks.next": "Next page", + "PaginationLinks.previous": "Previous page", "SaleDetailsPanel.listingAcceptedTitle": "{customerName} has booked {title}.", "SaleDetailsPanel.listingRejectedTitle": "{customerName} requested to book {title}.", "SaleDetailsPanel.listingRequestedTitle": "{customerName} has requested to book {title}.", @@ -75,8 +80,8 @@ "SaleDetailsPanel.saleRejectedStatus": "You rejected the booking.", "SaleDetailsPanel.saleRequestedStatus": "{customerName} is waiting for your response.", "SalePage.acceptButton": "Accept", - "SalePage.rejectButton": "Reject", "SalePage.loadingData": "Loading sale data.", + "SalePage.rejectButton": "Reject", "SalePage.title": "Sale details for ${title}.", "SearchPage.foundResults": "{count, number} {count, plural, one {listing} other {listings}} found.", "SearchPage.loadingResults": "Loading search results...",