From 2e63b87aba8ded2814b249cf3d735f16409eba74 Mon Sep 17 00:00:00 2001 From: David Joy Date: Thu, 14 Feb 2019 16:07:53 -0500 Subject: [PATCH] Tests for the saga, AsyncActionType, and profile actions. Also changing the way the saga gets ahold of the UserAccountApiService to make it more testable. --- src/actions/AsyncActionType.js | 10 +-- src/actions/AsyncActionType.test.js | 13 +++ src/actions/profile.test.js | 22 +++++ src/data/store.js | 6 +- src/sagas/RootSaga.js | 13 ++- src/sagas/RootSaga.test.js | 121 ++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 src/actions/AsyncActionType.test.js create mode 100644 src/sagas/RootSaga.test.js diff --git a/src/actions/AsyncActionType.js b/src/actions/AsyncActionType.js index dd9746a..cdde42d 100644 --- a/src/actions/AsyncActionType.js +++ b/src/actions/AsyncActionType.js @@ -10,6 +10,10 @@ export default class AsyncActionType { this.name = name; } + get BASE() { + return `${this.topic}__${this.name}`; + } + get BEGIN() { return `${this.topic}__${this.name}__BEGIN`; } @@ -25,10 +29,4 @@ export default class AsyncActionType { get RESET() { return `${this.topic}__${this.name}__RESET`; } - - get BASE() { - return `${this.topic}__${this.name}`; - } } - -AsyncActionType.prototype.toString = () => `${this.topic}__${this.name}`; diff --git a/src/actions/AsyncActionType.test.js b/src/actions/AsyncActionType.test.js new file mode 100644 index 0000000..70a9b5a --- /dev/null +++ b/src/actions/AsyncActionType.test.js @@ -0,0 +1,13 @@ +import AsyncActionType from './AsyncActionType'; + +describe('AsyncActionType', () => { + it('should return well formatted action strings', () => { + const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE'); + + expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE'); + expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN'); + expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS'); + expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE'); + expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET'); + }); +}); diff --git a/src/actions/profile.test.js b/src/actions/profile.test.js index a8b41c9..56b6afb 100644 --- a/src/actions/profile.test.js +++ b/src/actions/profile.test.js @@ -1,4 +1,8 @@ import { + openEditableField, + closeEditableField, + EDITABLE_FIELD_OPEN, + EDITABLE_FIELD_CLOSE, SAVE_USER_PROFILE, saveUserProfileBegin, saveUserProfileSuccess, @@ -7,6 +11,24 @@ import { saveUserProfile, } from './profile'; +describe('editable field actions', () => { + it('should create an open action', () => { + const expectedAction = { + type: EDITABLE_FIELD_OPEN, + fieldName: 'name', + }; + expect(openEditableField('name')).toEqual(expectedAction); + }); + + it('should create a closed action', () => { + const expectedAction = { + type: EDITABLE_FIELD_CLOSE, + fieldName: 'name', + }; + expect(closeEditableField('name')).toEqual(expectedAction); + }); +}); + describe('SAVE profile actions', () => { const userAccountState = { username: 'verified', diff --git a/src/data/store.js b/src/data/store.js index 43523b0..0716c62 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -3,6 +3,7 @@ import createSagaMiddleware from 'redux-saga'; import thunkMiddleware from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; import { createLogger } from 'redux-logger'; +import { UserAccountApiService } from '@edx/frontend-auth'; import apiClient from './apiClient'; import reducers from './reducers/RootReducer'; @@ -17,6 +18,9 @@ const store = createStore( composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)), ); -sagaMiddleware.run(rootSaga); +const apiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL); +apiService.saveUserAccount = apiService.saveUserAccount.bind(apiService); + +sagaMiddleware.run(rootSaga, apiService); export default store; diff --git a/src/sagas/RootSaga.js b/src/sagas/RootSaga.js index f25b338..c957fac 100644 --- a/src/sagas/RootSaga.js +++ b/src/sagas/RootSaga.js @@ -1,5 +1,4 @@ import { call, put, takeEvery, delay } from 'redux-saga/effects'; -import { UserAccountApiService } from '@edx/frontend-auth'; import { SAVE_USER_PROFILE, @@ -9,9 +8,8 @@ import { saveUserProfileReset, closeEditableField, } from '../actions/profile'; -import apiClient from '../data/apiClient'; -const apiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL); +let userAccountApiService = null; const PROP_TO_STATE_MAP = { fullName: 'name', @@ -20,7 +18,7 @@ const PROP_TO_STATE_MAP = { socialLinks: socialLinks => socialLinks.filter(({ socialLink }) => socialLink !== null), }; -const mapDataForRequest = (props) => { +export const mapDataForRequest = (props) => { const state = {}; Object.keys(props).forEach((prop) => { const propModifier = PROP_TO_STATE_MAP[prop] || prop; @@ -33,12 +31,12 @@ const mapDataForRequest = (props) => { return state; }; -function* saveUserProfile(action) { +export function* saveUserProfile(action) { try { yield put(saveUserProfileBegin()); const userAccount = yield call( // Because apiService is a class, 'this' needs to be bound. - apiService.saveUserAccount.bind(apiService), + userAccountApiService.saveUserAccount, action.payload.username, mapDataForRequest(action.payload.userAccountState), ); @@ -60,6 +58,7 @@ function* saveUserProfile(action) { } } -export default function* rootSaga() { +export default function* rootSaga(apiService) { + userAccountApiService = apiService; yield takeEvery(SAVE_USER_PROFILE.BASE, saveUserProfile); } diff --git a/src/sagas/RootSaga.test.js b/src/sagas/RootSaga.test.js new file mode 100644 index 0000000..24f9ae6 --- /dev/null +++ b/src/sagas/RootSaga.test.js @@ -0,0 +1,121 @@ +import { takeEvery, put, call, delay } from 'redux-saga/effects'; + +import rootSaga, { saveUserProfile, mapDataForRequest } from './RootSaga'; +import * as profileActions from '../actions/profile'; + +class MockUserAccountApiService { + constructor() { + this.saveUserAccount = jest.fn(); + } +} + +describe('RootSaga', () => { + describe('rootSaga', () => { + it('should pass actions to the correct sagas', () => { + const gen = rootSaga(); + // There is only one. + expect(gen.next().value) + .toEqual(takeEvery(profileActions.SAVE_USER_PROFILE.BASE, saveUserProfile)); + // ... and done. + expect(gen.next().value).toBeUndefined(); + }); + }); + + describe('saveUserProfile', () => { + it('should successfully process a saveUserProfile request if there are no exceptions', () => { + const service = new MockUserAccountApiService(); + const rootGen = rootSaga(service); + rootGen.next(); // Causes the service to be set. + + const action = profileActions.saveUserProfile( + 'my username', + { + fullName: 'Full Name', + education: 'b', + }, + 'ze field', + ); + const gen = saveUserProfile(action); + const userAccount = { + name: 'Full Name', + levelOfEducation: 'b', + }; + expect(gen.next().value).toEqual(put(profileActions.saveUserProfileBegin())); + expect(gen.next().value).toEqual(call(service.saveUserAccount, 'my username', userAccount)); + // The library would supply the result of the above call + // as the parameter to the NEXT yield. Here: + expect(gen.next(userAccount).value).toEqual(put(profileActions.saveUserProfileSuccess())); + expect(gen.next().value).toEqual(put({ + type: 'FETCH_USER_ACCOUNT_SUCCESS', + payload: { userAccount }, + })); + expect(gen.next().value).toEqual(delay(300)); + expect(gen.next().value).toEqual(put(profileActions.closeEditableField('ze field'))); + expect(gen.next().value).toEqual(delay(300)); + expect(gen.next().value).toEqual(put(profileActions.saveUserProfileReset())); + expect(gen.next().value).toBeUndefined(); + }); + + it('should successfully publish a failure action on exception', () => { + const service = new MockUserAccountApiService(); + const error = new Error('uhoh'); + const rootGen = rootSaga(service); + rootGen.next(); // Causes the service to be set. + + const action = profileActions.saveUserProfile( + 'my username', + { + fullName: 'Full Name', + education: 'b', + }, + 'ze field', + ); + const gen = saveUserProfile(action); + + expect(gen.next().value).toEqual(put(profileActions.saveUserProfileBegin())); + const result = gen.throw(error); + expect(result.value).toEqual(put(profileActions.saveUserProfileFailure(error))); + expect(gen.next().value).toBeUndefined(); + }); + }); + + describe('mapDataForRequest', () => { + it('should modify props according to prop modifier strings and functions', () => { + const props = { + favoriteColor: 'red', + age: 30, + petName: 'Donkey', + fullName: 'Donkey McWafflebatter', + userLocation: 'US', + education: 'BS', + socialLinks: { + twitter: { + url: 'https://www.twitter.com', + }, + facebook: { + url: 'https://www.facebook.com', + }, + }, + }; + const result = mapDataForRequest(props); + expect(result).toEqual({ + favoriteColor: 'red', + age: 30, + petName: 'Donkey', + name: 'Donkey McWafflebatter', + country: 'US', + levelOfEducation: 'BS', + socialLinks: [ + { + platform: 'twitter', + socialLink: 'https://www.twitter.com', + }, + { + platform: 'facebook', + socialLink: 'https://www.facebook.com', + }, + ], + }); + }); + }); +});