From e70b22f6cd326a8f2d336a601a8280b36ada6b14 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Mon, 30 Nov 2020 00:26:53 +0900 Subject: [PATCH 1/2] [Feature] Login function implementation - Implement login logic of Redux and components --- src/containers/auth/LoginFormContainer.jsx | 30 +++++++- .../auth/LoginFormContainer.test.jsx | 46 ++++++++++++ src/containers/auth/RegisterFormContainer.jsx | 19 +++-- .../auth/RegisterFormContainer.test.jsx | 21 +++++- src/reducers/slice.js | 32 +++++++- src/reducers/slice.test.js | 74 ++++++++++++++++++- src/services/__mocks__/api.js | 2 + 7 files changed, 206 insertions(+), 18 deletions(-) diff --git a/src/containers/auth/LoginFormContainer.jsx b/src/containers/auth/LoginFormContainer.jsx index 3d450e0..1a7e44b 100644 --- a/src/containers/auth/LoginFormContainer.jsx +++ b/src/containers/auth/LoginFormContainer.jsx @@ -1,16 +1,20 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; import { get } from '../../util/utils'; -import { changeAuthField } from '../../reducers/slice'; +import { changeAuthField, clearAuth, requestLogin } from '../../reducers/slice'; import AuthForm from '../../components/auth/AuthForm'; const LoginFormContainer = () => { const dispatch = useDispatch(); + const history = useHistory(); const login = useSelector(get('login')); + const user = useSelector(get('user')); + const authError = useSelector(get('authError')); const onChangeLoginField = useCallback(({ name, value }) => { dispatch( @@ -22,11 +26,33 @@ const LoginFormContainer = () => { ); }); + const onSubmit = useCallback(() => { + // TODO: 로그인 validation 체크 로직 추가 + + dispatch(requestLogin()); + }, [dispatch]); + + useEffect(() => { + if (user) { + history.push('/'); + } + + if (authError) { + // TODO: error 처리 추가 + console.error(authError); + } + }, [user, authError]); + + useEffect(() => () => { + dispatch(clearAuth()); + }, [dispatch]); + return ( ); }; diff --git a/src/containers/auth/LoginFormContainer.test.jsx b/src/containers/auth/LoginFormContainer.test.jsx index 08a3495..801007c 100644 --- a/src/containers/auth/LoginFormContainer.test.jsx +++ b/src/containers/auth/LoginFormContainer.test.jsx @@ -6,11 +6,22 @@ import { render, fireEvent } from '@testing-library/react'; import LoginFormContainer from './LoginFormContainer'; +const mockPush = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory() { + return { push: mockPush }; + }, +})); + describe('LoginFormContainer', () => { const dispatch = jest.fn(); beforeEach(() => { dispatch.mockClear(); + mockPush.mockClear(); + useDispatch.mockImplementation(() => dispatch); useSelector.mockImplementation((selector) => selector({ @@ -18,6 +29,8 @@ describe('LoginFormContainer', () => { userEmail: '', password: '', }, + user: given.user, + authError: given.authError, })); }); @@ -59,5 +72,38 @@ describe('LoginFormContainer', () => { }); }); }); + + it('submit event calls dispatch', () => { + const { getByTestId } = renderLoginFormContainer(); + + const button = getByTestId('auth-button'); + + expect(button).not.toBeNull(); + + fireEvent.submit(button); + + expect(dispatch).toBeCalled(); + }); + }); + + describe('actions after login', () => { + context('when success auth to login', () => { + given('user', () => ({ + user: 'seungmin@naver.com', + })); + + it('redirection go to main page', () => { + renderLoginFormContainer(); + + expect(mockPush).toBeCalledWith('/'); + }); + }); + + // TODO: 현재 authError는 콘솔 출력 + context('when failure auth to login', () => { + given('authError', () => ({ + authError: 'error', + })); + }); }); }); diff --git a/src/containers/auth/RegisterFormContainer.jsx b/src/containers/auth/RegisterFormContainer.jsx index 036d1e9..8a2954a 100644 --- a/src/containers/auth/RegisterFormContainer.jsx +++ b/src/containers/auth/RegisterFormContainer.jsx @@ -14,6 +14,7 @@ const RegisterFormContainer = () => { const register = useSelector(get('register')); const auth = useSelector(get('auth')); + const user = useSelector(get('user')); const authError = useSelector(get('authError')); const onChangeRegisterField = useCallback(({ name, value }) => { @@ -27,23 +28,31 @@ const RegisterFormContainer = () => { }, [dispatch]); const onSubmit = useCallback(() => { + // TODO: 회원가입 validation 체크 로직 추가 + dispatch(requestRegister()); }, [dispatch]); + useEffect(() => { + if (user) { + history.push('/'); + } + }, [user]); + useEffect(() => { if (auth) { history.push('/login'); } if (authError) { - // TODO: 추 후 error 처리 + // TODO: error 처리 추가 console.error(authError); } + }, [auth, authError]); - return () => { - dispatch(clearAuth()); - }; - }, [dispatch, auth, authError]); + useEffect(() => () => { + dispatch(clearAuth()); + }, [dispatch]); return ( { beforeEach(() => { dispatch.mockClear(); + mockPush.mockClear(); + useDispatch.mockImplementation(() => dispatch); useSelector.mockImplementation((selector) => selector({ + user: given.user, + auth: given.auth, + authError: given.authError, register: { userEmail: '', password: '', passwordConfirm: '', }, - auth: given.auth, - authError: given.authError, })); }); @@ -90,7 +93,7 @@ describe('RegisterFormContainer', () => { }); }); - describe('action after signing up', () => { + describe('actions after signing up', () => { context('when success auth to register', () => { given('auth', () => ({ auth: 'seungmin@naver.com', @@ -110,4 +113,16 @@ describe('RegisterFormContainer', () => { })); }); }); + + describe('action after login', () => { + given('user', () => ({ + user: 'seungmin@naver.com', + })); + + it('redirection go to main page', () => { + renderRegisterFormContainer(); + + expect(mockPush).toBeCalledWith('/'); + }); + }); }); diff --git a/src/reducers/slice.js b/src/reducers/slice.js index dedcf5b..94752b1 100644 --- a/src/reducers/slice.js +++ b/src/reducers/slice.js @@ -6,6 +6,7 @@ import { getStudyGroup, getStudyGroups, postStudyGroup, + postUserLogin, postUserRegister, } from '../services/api'; @@ -37,11 +38,12 @@ const { actions, reducer } = createSlice({ groups: [], group: null, groupId: null, + user: null, + auth: null, + authError: null, writeField: writeInitialState, register: authInitialState.register, login: authInitialState.login, - auth: null, - authError: null, }, reducers: { @@ -109,10 +111,10 @@ const { actions, reducer } = createSlice({ }; }, - setAuth(state, { payload: user }) { + setAuth(state, { payload: auth }) { return { ...state, - auth: user, + auth, }; }, @@ -130,6 +132,13 @@ const { actions, reducer } = createSlice({ authError: null, }; }, + + setUser(state, { payload: user }) { + return { + ...state, + user, + }; + }, }, }); @@ -144,6 +153,7 @@ export const { setAuth, setAuthError, clearAuth, + setUser, } = actions; export const loadStudyGroups = (tag) => async (dispatch) => { @@ -188,4 +198,18 @@ export const requestRegister = () => async (dispatch, getState) => { } }; +export const requestLogin = () => async (dispatch, getState) => { + const { login: { userEmail, password } } = getState(); + + try { + const { user } = await postUserLogin({ userEmail, password }); + + dispatch(setUser(user.email)); + + dispatch(clearAuthFields()); + } catch (error) { + setAuthError(error); + } +}; + export default reducer; diff --git a/src/reducers/slice.test.js b/src/reducers/slice.test.js index 96e4e62..dc41840 100644 --- a/src/reducers/slice.test.js +++ b/src/reducers/slice.test.js @@ -18,12 +18,14 @@ import reducer, { setAuthError, clearAuth, requestRegister, + requestLogin, + setUser, } from './slice'; import STUDY_GROUPS from '../../fixtures/study-groups'; import STUDY_GROUP from '../../fixtures/study-group'; import WRITE_FORM from '../../fixtures/write-form'; -import { postUserRegister } from '../services/api'; +import { postUserLogin, postUserRegister } from '../services/api'; const middlewares = [thunk]; const mockStore = configureStore(middlewares); @@ -36,6 +38,9 @@ describe('reducer', () => { groups: [], group: null, groupId: null, + user: null, + auth: null, + authError: null, writeField: { title: '', contents: '', @@ -54,8 +59,6 @@ describe('reducer', () => { userEmail: '', password: '', }, - auth: null, - authError: null, }; it('returns initialState', () => { @@ -261,13 +264,27 @@ describe('reducer', () => { authError: 'error', }; - it('Clean up to auth and authError', () => { + it('clean up to auth and authError', () => { const { auth, authError } = reducer(initialState, clearAuth()); expect(auth).toBe(null); expect(authError).toBe(null); }); }); + + describe('setUser', () => { + const initialState = { + user: null, + }; + + const userEmail = 'seungmin@naver.com'; + + it('success login', () => { + const { user } = reducer(initialState, setUser(userEmail)); + + expect(user).toBe(userEmail); + }); + }); }); describe('async actions', () => { @@ -342,6 +359,7 @@ describe('async actions', () => { email: userEmail, }, })); + it('dispatches requestRegister action success to return user email', async () => { await store.dispatch(requestRegister()); @@ -368,4 +386,52 @@ describe('async actions', () => { }); }); }); + + describe('requestLogin', () => { + const login = { + userEmail: 'seungmin@naver.com', + password: '123456', + }; + + beforeEach(() => { + store = mockStore({ + login, + }); + }); + + context('without auth error', () => { + const { userEmail } = login; + + postUserLogin.mockImplementationOnce(() => ({ + user: { + email: userEmail, + }, + })); + + it('dispatches requestLogin action success to return user email', async () => { + await store.dispatch(requestLogin()); + + const actions = store.getActions(); + + expect(actions[0]).toEqual(setUser(userEmail)); + expect(actions[1]).toEqual(clearAuthFields()); + }); + }); + + context('with auth error', () => { + postUserLogin.mockImplementationOnce(() => { + throw new Error('error'); + }); + + it('dispatches requestLogin action failure to return error', async () => { + try { + await store.dispatch(requestLogin()); + } catch (error) { + const actions = store.getActions(); + + expect(actions[0]).toEqual(setAuthError(error)); + } + }); + }); + }); }); diff --git a/src/services/__mocks__/api.js b/src/services/__mocks__/api.js index 2dee810..4f4819b 100644 --- a/src/services/__mocks__/api.js +++ b/src/services/__mocks__/api.js @@ -5,3 +5,5 @@ export const getStudyGroup = async () => {}; export const postStudyGroup = async () => {}; export const postUserRegister = jest.fn(); + +export const postUserLogin = jest.fn(); From f581d1f30986b97762c41be54668eea6199350f3 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Mon, 30 Nov 2020 01:26:17 +0900 Subject: [PATCH 2/2] [Feature] Implementing the login function using localstorage - setItem and loadItem - change header status --- src/App.jsx | 40 ++++++++++++++------- src/App.test.jsx | 25 +++++++++++-- src/components/common/Header.jsx | 16 ++++++--- src/containers/common/HeaderContainer.jsx | 17 ++++++--- src/reducers/slice.js | 8 ++++- src/services/__mocks__/storage.js | 9 +++++ src/services/storage.js | 11 ++++++ src/services/storage.test.js | 44 +++++++++++++++++++++++ 8 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 src/services/__mocks__/storage.js create mode 100644 src/services/storage.js create mode 100644 src/services/storage.test.js diff --git a/src/App.jsx b/src/App.jsx index c41d189..d89f31f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,11 @@ import React from 'react'; +import { useDispatch } from 'react-redux'; import { Switch, Route } from 'react-router-dom'; +import { loadItem } from './services/storage'; +import { setUser } from './reducers/slice'; + import MainPage from './pages/MainPage'; import WritePage from './pages/WritePage'; import IntroducePage from './pages/IntroducePage'; @@ -9,17 +13,29 @@ import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import HeaderContainer from './containers/common/HeaderContainer'; -const App = () => ( - <> - - - - - - - - - -); +const App = () => { + const dispatch = useDispatch(); + + const user = loadItem('user'); + + if (user) { + const { email } = user; + + dispatch(setUser(email)); + } + + return ( + <> + + + + + + + + + + ); +}; export default App; diff --git a/src/App.test.jsx b/src/App.test.jsx index d75e939..33b2479 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1,17 +1,19 @@ import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - import { MemoryRouter } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import { render } from '@testing-library/react'; import App from './App'; +import { loadItem } from './services/storage'; + import STUDY_GROUPS from '../fixtures/study-groups'; import STUDY_GROUP from '../fixtures/study-group'; jest.mock('react-redux'); +jest.mock('./services/storage'); describe('App', () => { const dispatch = jest.fn(); @@ -87,4 +89,23 @@ describe('App', () => { expect(container).toHaveTextContent('회원가입'); }); }); + + context('when logged in', () => { + const user = { + email: 'seungmin@naver.com', + }; + + beforeEach(() => { + loadItem.mockImplementation(() => user); + }); + + it('calls dispatch with "setUser" action', () => { + renderApp({ path: '/' }); + + expect(dispatch).toBeCalledWith({ + type: 'application/setUser', + payload: user.email, + }); + }); + }); }); diff --git a/src/components/common/Header.jsx b/src/components/common/Header.jsx index eaa9008..762f183 100644 --- a/src/components/common/Header.jsx +++ b/src/components/common/Header.jsx @@ -43,15 +43,21 @@ const Spacer = styled.div` height: 6rem; `; -const Header = () => ( +const Header = ({ user }) => ( <> 제목(미정) -
- 로그인 - 회원가입 -
+ {user ? ( +
+ +
+ ) : ( +
+ 로그인 + 회원가입 +
+ )}
diff --git a/src/containers/common/HeaderContainer.jsx b/src/containers/common/HeaderContainer.jsx index 108fc19..c711dc3 100644 --- a/src/containers/common/HeaderContainer.jsx +++ b/src/containers/common/HeaderContainer.jsx @@ -1,10 +1,19 @@ import React from 'react'; +import { useSelector } from 'react-redux'; + +import { get } from '../../util/utils'; + import Header from '../../components/common/Header'; -const HeaderContainer = () => (( -
-) -); +const HeaderContainer = () => { + const user = useSelector(get('user')); + + return ( +
+ ); +}; export default HeaderContainer; diff --git a/src/reducers/slice.js b/src/reducers/slice.js index 94752b1..4d6e757 100644 --- a/src/reducers/slice.js +++ b/src/reducers/slice.js @@ -9,6 +9,7 @@ import { postUserLogin, postUserRegister, } from '../services/api'; +import { saveItem } from '../services/storage'; const writeInitialState = { title: '', @@ -204,8 +205,13 @@ export const requestLogin = () => async (dispatch, getState) => { try { const { user } = await postUserLogin({ userEmail, password }); - dispatch(setUser(user.email)); + const { email } = user; + saveItem('user', { + email, + }); + + dispatch(setUser(email)); dispatch(clearAuthFields()); } catch (error) { setAuthError(error); diff --git a/src/services/__mocks__/storage.js b/src/services/__mocks__/storage.js new file mode 100644 index 0000000..3135add --- /dev/null +++ b/src/services/__mocks__/storage.js @@ -0,0 +1,9 @@ +const saveItem = jest.fn(); +const loadItem = jest.fn(); +const removeItem = jest.fn(); + +export { + saveItem, + loadItem, + removeItem, +}; diff --git a/src/services/storage.js b/src/services/storage.js new file mode 100644 index 0000000..ca53a99 --- /dev/null +++ b/src/services/storage.js @@ -0,0 +1,11 @@ +export const saveItem = (key, value) => { + localStorage.setItem(key, JSON.stringify(value)); +}; + +export const loadItem = (key) => { + const user = localStorage.getItem(key); + + return JSON.parse(user); +}; + +export const removeItem = (key) => localStorage.removeItem(key); diff --git a/src/services/storage.test.js b/src/services/storage.test.js new file mode 100644 index 0000000..bd8f97d --- /dev/null +++ b/src/services/storage.test.js @@ -0,0 +1,44 @@ +import { saveItem, loadItem, removeItem } from './storage'; + +describe('storage', () => { + jest.spyOn(window.localStorage.__proto__, 'setItem'); + jest.spyOn(window.localStorage.__proto__, 'getItem'); + + beforeEach(() => { + const mockStorage = {}; + + window.localStorage = { + setItem: (key, val) => Object.assign(mockStorage, { [key]: val }), + getItem: (key) => mockStorage[key], + }; + + window.localStorage.__proto__.removeItem = jest.fn(); + }); + + describe('saveItem', () => { + const value = { + value: 'value', + }; + it('calls localStorage setItem', () => { + saveItem('key', value); + + expect(localStorage.setItem).toBeCalledWith('key', JSON.stringify(value)); + }); + }); + + describe('loadItem', () => { + it('calls localStorage getItem', () => { + loadItem('key'); + + expect(localStorage.getItem).toBeCalledWith('key'); + }); + }); + + describe('removeItem', () => { + it('calls localStorage removeItem', () => { + removeItem('key'); + + expect(localStorage.removeItem).toBeCalled(); + }); + }); +});