Tests for the saga, AsyncActionType, and profile actions.

Also changing the way the saga gets ahold of the UserAccountApiService to make it more testable.
This commit is contained in:
David Joy
2019-02-14 16:07:53 -05:00
committed by David Joy
parent bd9750fc58
commit 2e63b87aba
6 changed files with 171 additions and 14 deletions

View File

@@ -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}`;

View File

@@ -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');
});
});

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);
}

121
src/sagas/RootSaga.test.js Normal file
View File

@@ -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',
},
],
});
});
});
});