mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-31 02:26:50 +10:00
Refactor authentication from redux-saga to redux-thunk
This commit is contained in:
parent
2a351c9247
commit
30f9909b45
11 changed files with 305 additions and 283 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ? <Redirect to={from} /> : <NamedRedirect name="LandingPage" />;
|
||||
|
||||
// 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 (
|
||||
<PageLayout title={`Authentication page: ${tab} tab`}>
|
||||
<PageLayout title={title}>
|
||||
<div className={css.root}>
|
||||
{isAuthenticated ? authRedirect : null}
|
||||
{loginError
|
||||
|
|
@ -46,7 +56,7 @@ export const AuthenticationPageComponent = props => {
|
|||
</p>
|
||||
: null}
|
||||
{isLogin
|
||||
? <LoginForm onSubmit={onLoginSubmit} />
|
||||
? <LoginForm onSubmit={onLoginSubmit} inProgress={authInProgress} />
|
||||
: <SignUpForm onSubmit={onSignUpSubmit} />}
|
||||
{/* {isLogin
|
||||
? <NamedLink name="SignUpPage" to={{ state: from ? { from } : null }}>Sign up</NamedLink>
|
||||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<AuthenticationPageComponent {...props} />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
exports[`AuthenticationPageComponent matches snapshot 1`] = `
|
||||
<Connect(withRouter(PageLayout))
|
||||
title="Authentication page: signup tab">
|
||||
title="AuthenticationPage.loginPageTitle">
|
||||
<div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
|
|
@ -11,6 +11,7 @@ exports[`AuthenticationPageComponent matches snapshot 1`] = `
|
|||
</code>
|
||||
</p>
|
||||
<ReduxForm
|
||||
inProgress={false}
|
||||
onSubmit={[Function]} />
|
||||
</div>
|
||||
</Connect(withRouter(PageLayout))>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form className={css.form} onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<div className={css.row}>
|
||||
<LabeledField name="email" type="email" label="Email" />
|
||||
<LabeledField name="email" type="email" label={emailLabel} />
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<LabeledField name="password" type="password" label="Password" />
|
||||
<LabeledField name="password" type="password" label={passwordLabel} />
|
||||
</div>
|
||||
</div>
|
||||
<Button className={css.button} type="submit" disabled={pristine || submitting}>Log in</Button>
|
||||
<Button className={css.button} type="submit" disabled={submitDisabled}>
|
||||
<FormattedMessage id="LoginForm.logIn" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ exports[`LoginForm matches snapshot 1`] = `
|
|||
className=""
|
||||
disabled={true}
|
||||
type="submit">
|
||||
Log in
|
||||
<span>
|
||||
Log in
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 = () => <span dangerouslySetInnerHTML={{ __html: '🏠' }} />;
|
||||
/* 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
|
||||
? <Button className={css.logoutButton} onClick={handleLogout}>Logout</Button>
|
||||
: <NamedLink name="LogInPage">Login</NamedLink>;
|
||||
|
||||
return (
|
||||
<div className={css.container}>
|
||||
<div>
|
||||
|
|
@ -27,30 +38,44 @@ const Topbar = props => {
|
|||
</NamedLink>
|
||||
</div>
|
||||
<div className={css.user}>
|
||||
{isAuthenticated
|
||||
? <Button className={css.logoutButton} onClick={handleLogout}>Logout</Button>
|
||||
: <NamedLink name="LogInPage">Login</NamedLink>}
|
||||
{authInProgress ? null : authAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue