diff --git a/package-lock.json b/package-lock.json index 2558c6bc6..208b4088a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6650,7 +6650,8 @@ "eslint-import-resolver-alias": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", - "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==" + "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==", + "dev": true }, "eslint-import-resolver-node": { "version": "0.3.6", @@ -11928,6 +11929,11 @@ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "dev": true }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -14858,6 +14864,14 @@ "deep-diff": "^0.3.5" } }, + "redux-mock-store": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", + "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", + "requires": { + "lodash.isplainobject": "^4.0.6" + } + }, "redux-saga": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", diff --git a/package.json b/package.json index 7a762f9d3..cc4c4fc7d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "redux": "4.1.2", "redux-devtools-extension": "^2.13.9", "redux-logger": "^3.0.6", + "redux-mock-store": "^1.5.4", "redux-thunk": "^2.4.1", "reselect": "^4.1.5", "tinymce": "^5.10.2" diff --git a/src/editors/data/redux/app/reducer.test.js b/src/editors/data/redux/app/reducer.test.js new file mode 100644 index 000000000..7986db6ff --- /dev/null +++ b/src/editors/data/redux/app/reducer.test.js @@ -0,0 +1,59 @@ +import { initialState, actions, reducer } from './reducer'; + +const testingState = { + ...initialState, + arbitraryField: 'arbitrary', +}; + +describe('app reducer', () => { + it('has initial state', () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + const testValue = 'roll for initiative'; + + describe('handling actions', () => { + describe('initialize', () => { + it('loads initial input fields into the store', () => { + const data = { + studioEndpointUrl: 'testURL', + blockId: 'anID', + courseId: 'OTHERid', + blockType: 'someTYPE', + }; + expect(reducer( + testingState, + actions.initialize({ ...data, other: 'field' }), + )).toEqual({ + ...testingState, + ...data, + }); + }); + }); + const setterTest = (action, target) => { + describe(action, () => { + it(`load ${target} from payload`, () => { + expect(reducer(testingState, actions[action](testValue))).toEqual({ + ...testingState, + [target]: testValue, + }); + }); + }); + }; + [ + ['setUnitUrl', 'unitUrl'], + ['setBlockValue', 'blockValue'], + ['setBlockContent', 'blockContent'], + ['setBlockTitle', 'blockTitle'], + ['setSaveResponse', 'saveResponse'], + ].map(args => setterTest(...args)); + describe('initializeEditor', () => { + it('sets editorInitialized to true', () => { + expect(reducer(testingState, actions.initializeEditor())).toEqual({ + ...testingState, + editorInitialized: true, + }); + }); + }); + }); +}); diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.js new file mode 100644 index 000000000..edb28332f --- /dev/null +++ b/src/editors/data/redux/app/selectors.test.js @@ -0,0 +1,98 @@ +// import * in order to mock in-file references +import * as urls from '../../services/cms/urls'; +import * as selectors from './selectors'; + +jest.mock('reselect', () => ({ + createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), +})); +jest.mock('../../services/cms/urls', () => ({ + unit: (args) => ({ unit: args }), +})); + +const testState = { some: 'arbitraryValue' }; +const testValue = 'my VALUE'; + +describe('app selectors unit tests', () => { + const { + appSelector, + simpleSelectors, + } = selectors; + describe('appSelector', () => { + it('returns the app data', () => { + expect(appSelector({ ...testState, app: testValue })).toEqual(testValue); + }); + }); + describe('simpleSelectors', () => { + const testSimpleSelector = (key) => { + test(`${key} simpleSelector returns its value from the app store`, () => { + const { preSelectors, cb } = simpleSelectors[key]; + expect(preSelectors).toEqual([appSelector]); + expect(cb({ ...testState, [key]: testValue })).toEqual(testValue); + }); + }; + describe('simple selectors link their values from app store', () => { + [ + 'blockContent', + 'blockId', + 'blockType', + 'blockValue', + 'courseId', + 'editorInitialized', + 'saveResponse', + 'studioEndpointUrl', + 'unitUrl', + ].map(testSimpleSelector); + }); + }); + describe('returnUrl', () => { + it('is memoized based on unitUrl and studioEndpointUrl', () => { + expect(selectors.returnUrl.preSelectors).toEqual([ + simpleSelectors.unitUrl, + simpleSelectors.studioEndpointUrl, + ]); + }); + it('returns urls.unit with the unitUrl if loaded, else an empty string', () => { + const { cb } = selectors.returnUrl; + const studioEndpointUrl = 'baseURL'; + const unitUrl = 'some unit url'; + expect(cb(null, studioEndpointUrl)).toEqual(''); + expect(cb(unitUrl, studioEndpointUrl)).toEqual(urls.unit({ unitUrl, studioEndpointUrl })); + }); + }); + describe('isInitialized selector', () => { + it('is memoized based on unitUrl, editorInitialized, and blockValue', () => { + expect(selectors.isInitialized.preSelectors).toEqual([ + simpleSelectors.unitUrl, + simpleSelectors.editorInitialized, + simpleSelectors.blockValue, + ]); + }); + it('returns true iff unitUrl, blockValue, and editorInitialized are all truthy', () => { + const { cb } = selectors.isInitialized; + const truthy = { + url: { url: 'data' }, + blockValue: { block: 'value' }, + editorInitialized: true, + }; + + [ + [[truthy.url, truthy.blockValue, false], false], + [[null, truthy.blockValue, true], false], + [[truthy.url, null, true], false], + [[truthy.url, truthy.blockValue, true], true], + ].map(([args, expected]) => expect(cb(...args)).toEqual(expected)); + }); + }); + describe('typeHeader', () => { + it('is memoized based on blockType', () => { + expect(selectors.typeHeader.preSelectors).toEqual([simpleSelectors.blockType]); + }); + it('returns Text if the blockType is html', () => { + expect(selectors.typeHeader.cb('html')).toEqual('Text'); + }); + it('returns the blockType capitalized if not html', () => { + expect(selectors.typeHeader.cb('video')).toEqual('Video'); + expect(selectors.typeHeader.cb('random')).toEqual('Random'); + }); + }); +}); diff --git a/src/editors/data/redux/requests/selectors.js b/src/editors/data/redux/requests/selectors.js index 480944f2a..d66cf1384 100644 --- a/src/editors/data/redux/requests/selectors.js +++ b/src/editors/data/redux/requests/selectors.js @@ -4,7 +4,7 @@ import * as module from './selectors'; export const requestStatus = (state, { requestKey }) => state.requests[requestKey]; -const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]); +export const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]); export const isInactive = ({ status }) => status === RequestStates.inactive; export const isPending = ({ status }) => status === RequestStates.pending; @@ -19,15 +19,19 @@ export const errorCode = (request) => request.error?.response?.data; export const data = (request) => request.data; +export const connectedStatusSelectors = () => ({ + isInactive: module.statusSelector(isInactive), + isPending: module.statusSelector(isPending), + isCompleted: module.statusSelector(isCompleted), + isFailed: module.statusSelector(isFailed), + isFinished: module.statusSelector(isFinished), + error: module.statusSelector(error), + errorCode: module.statusSelector(errorCode), + errorStatus: module.statusSelector(errorStatus), + data: module.statusSelector(data), +}); + export default StrictDict({ requestStatus, - isInactive: statusSelector(isInactive), - isPending: statusSelector(isPending), - isCompleted: statusSelector(isCompleted), - isFailed: statusSelector(isFailed), - isFinished: statusSelector(isFinished), - error: statusSelector(error), - errorCode: statusSelector(errorCode), - errorStatus: statusSelector(errorStatus), - data: statusSelector(data), + ...module.connectedStatusSelectors(), }); diff --git a/src/editors/data/redux/requests/selectors.test.js b/src/editors/data/redux/requests/selectors.test.js new file mode 100644 index 000000000..444f3f6ab --- /dev/null +++ b/src/editors/data/redux/requests/selectors.test.js @@ -0,0 +1,122 @@ +import { RequestStates } from '../../constants/requests'; + +// import * in order to mock in-file references +import * as selectors from './selectors'; + +jest.mock('reselect', () => ({ + createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), +})); + +const testValue = 'my test VALUE'; +const testKey = 'MY test key'; + +describe('request selectors', () => { + describe('basic selectors', () => { + describe('requestStatus', () => { + it('returns the state associated with the given requestKey', () => { + expect( + selectors.requestStatus( + { requests: { [testKey]: testValue } }, + { requestKey: testKey }, + ), + ).toEqual(testValue); + }); + }); + describe('statusSelector', () => { + it('returns a state selector that applies a fn against request state by requestKey', () => { + const myMethod = ({ data }) => ({ myData: data }); + expect(selectors.statusSelector(myMethod)( + { requests: { [testKey]: { data: testValue } } }, + { requestKey: testKey }, + )).toEqual({ myData: testValue }); + }); + }); + describe('state selectors', () => { + const testStateSelector = (selector, expected) => { + describe(selector, () => { + it(`returns true iff the request status equals ${expected}`, () => { + expect(selectors[selector]({ status: expected })).toEqual(true); + expect(selectors[selector]({ status: 'other' })).toEqual(false); + }); + }); + }; + testStateSelector('isInactive', RequestStates.inactive); + testStateSelector('isPending', RequestStates.pending); + testStateSelector('isCompleted', RequestStates.completed); + testStateSelector('isFailed', RequestStates.failed); + describe('isFinished', () => { + it('returns true iff the request is completed or failed', () => { + expect(selectors.isFinished({ status: RequestStates.completed })).toEqual(true); + expect(selectors.isFinished({ status: RequestStates.failed })).toEqual(true); + expect(selectors.isFinished({ status: 'other' })).toEqual(false); + }); + }); + }); + describe('error selectors', () => { + describe('error', () => { + it('returns the error for the request', () => { + expect(selectors.error({ error: testValue })).toEqual(testValue); + }); + }); + describe('errorStatus', () => { + it('returns the status the error response iff one exists', () => { + expect(selectors.errorStatus({})).toEqual(undefined); + expect(selectors.errorStatus({ error: {} })).toEqual(undefined); + expect(selectors.errorStatus({ error: { response: {} } })).toEqual(undefined); + expect(selectors.errorStatus( + { error: { response: { status: testValue } } }, + )).toEqual(testValue); + }); + }); + describe('errorCode', () => { + it('returns the status the error code iff one exists', () => { + expect(selectors.errorCode({})).toEqual(undefined); + expect(selectors.errorCode({ error: {} })).toEqual(undefined); + expect(selectors.errorCode({ error: { response: {} } })).toEqual(undefined); + expect(selectors.errorCode( + { error: { response: { data: testValue } } }, + )).toEqual(testValue); + }); + }); + }); + describe('data', () => { + it('returns the data from the request', () => { + expect(selectors.data({ data: testValue })).toEqual(testValue); + }); + }); + }); + describe('exported selectors', () => { + test('requestStatus forwards basic selector', () => { + expect(selectors.default.requestStatus).toEqual(selectors.requestStatus); + }); + describe('statusSelector selectors', () => { + let statusSelector; + let connectedSelectors; + beforeEach(() => { + statusSelector = selectors.statusSelector; + selectors.statusSelector = jest.fn(key => ({ statusSelector: key })); + connectedSelectors = selectors.connectedStatusSelectors(); + }); + afterEach(() => { + selectors.statusSelector = statusSelector; + }); + const testStatusSelector = (name) => { + describe(name, () => { + it(`returns a status selector keyed to the ${name} selector`, () => { + expect(connectedSelectors[name].statusSelector).toEqual(selectors[name]); + }); + }); + }; + [ + 'isInactive', + 'isPending', + 'isCompleted', + 'isFailed', + 'error', + 'errorCode', + 'errorStatus', + 'data', + ].map(testStatusSelector); + }); + }); +}); diff --git a/src/editors/data/redux/thunkActions/.requests.js.swp b/src/editors/data/redux/thunkActions/.requests.js.swp index c4d7c512b..21b969227 100644 Binary files a/src/editors/data/redux/thunkActions/.requests.js.swp and b/src/editors/data/redux/thunkActions/.requests.js.swp differ diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index bd53bb704..b1d4937e3 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -1,6 +1,7 @@ import { StrictDict } from '../../../utils'; -import { actions, selectors } from '..'; +import { actions } from '..'; import * as requests from './requests'; +import * as module from './app'; export const fetchBlock = () => (dispatch) => { dispatch(requests.fetchBlock({ @@ -24,14 +25,14 @@ export const fetchUnit = () => (dispatch) => { */ export const initialize = (data) => (dispatch) => { dispatch(actions.app.initialize(data)); - dispatch(fetchBlock()); - dispatch(fetchUnit()); + dispatch(module.fetchBlock()); + dispatch(module.fetchUnit()); }; /** * @param {func} onSuccess */ -export const saveBlock = ({ content, returnToUnit }) => (dispatch, getState) => { +export const saveBlock = ({ content, returnToUnit }) => (dispatch) => { dispatch(actions.app.setBlockContent(content)); dispatch(requests.saveBlock({ content, diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 28d9ff524..91468bdfa 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -1,42 +1,95 @@ -import { locationId } from './data/constants/app'; - -import { actions } from './data/redux'; -import thunkActions from './app'; +import { actions } from '..'; +import * as thunkActions from './app'; jest.mock('./requests', () => ({ - initializeApp: (args) => ({ initializeApp: args }), + fetchBlock: (args) => ({ fetchBlock: args }), + fetchUnit: (args) => ({ fetchUnit: args }), + saveBlock: (args) => ({ saveBlock: args }), })); +const testValue = 'test VALUE'; + describe('app thunkActions', () => { let dispatch; let dispatchedAction; beforeEach(() => { dispatch = jest.fn((action) => ({ dispatch: action })); }); - describe('initialize', () => { + describe('fetchBlock', () => { beforeEach(() => { - thunkActions.initialize()(dispatch); + thunkActions.fetchBlock()(dispatch); [[dispatchedAction]] = dispatch.mock.calls; }); - it('dispatches initializeApp with locationId and onSuccess', () => { - expect(dispatchedAction.initializeApp.locationId).toEqual(locationId); - expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function'); + it('dispatches fetchBlock action', () => { + expect(dispatchedAction.fetchBlock).not.toEqual(undefined); }); - describe('on success', () => { - test('loads oraMetadata, courseMetadata and list data', () => { - dispatch.mockClear(); - const response = { - oraMetadata: { some: 'ora-metadata' }, - courseMetadata: { some: 'course-metadata' }, - submissions: { some: 'submissions' }, - }; - dispatchedAction.initializeApp.onSuccess(response); - expect(dispatch.mock.calls).toEqual([ - [actions.app.loadOraMetadata(response.oraMetadata)], - [actions.app.loadCourseMetadata(response.courseMetadata)], - [actions.submissions.loadList(response.submissions)], - ]); - }); + it('dispatches actions.app.setBlockValue on success', () => { + dispatch.mockClear(); + dispatchedAction.fetchBlock.onSuccess(testValue); + expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue)); + }); + it('dispatches actions.app.setBlockValue on failure', () => { + dispatch.mockClear(); + dispatchedAction.fetchBlock.onFailure(testValue); + expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue)); + }); + }); + describe('fetchUnit', () => { + beforeEach(() => { + thunkActions.fetchUnit()(dispatch); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches fetchUnit action', () => { + expect(dispatchedAction.fetchUnit).not.toEqual(undefined); + }); + it('dispatches actions.app.setUnitUrl on success', () => { + dispatch.mockClear(); + dispatchedAction.fetchUnit.onSuccess(testValue); + expect(dispatch).toHaveBeenCalledWith(actions.app.setUnitUrl(testValue)); + }); + it('dispatches actions.app.setUnitUrl on failure', () => { + dispatch.mockClear(); + dispatchedAction.fetchUnit.onFailure(testValue); + expect(dispatch).toHaveBeenCalledWith(actions.app.setUnitUrl(testValue)); + }); + }); + describe('initialize', () => { + it('dispatches actions.app.initialize, and then fetches both block and unit', () => { + const { fetchBlock, fetchUnit } = thunkActions; + thunkActions.fetchBlock = () => 'fetchBlock'; + thunkActions.fetchUnit = () => 'fetchUnit'; + thunkActions.initialize(testValue)(dispatch); + expect(dispatch.mock.calls).toEqual([ + [actions.app.initialize(testValue)], + [thunkActions.fetchBlock()], + [thunkActions.fetchUnit()], + ]); + thunkActions.fetchBlock = fetchBlock; + thunkActions.fetchUnit = fetchUnit; + }); + }); + describe('saveBlock', () => { + let returnToUnit; + let calls; + beforeEach(() => { + returnToUnit = jest.fn(); + thunkActions.saveBlock({ content: testValue, returnToUnit })(dispatch); + calls = dispatch.mock.calls; + }); + it('dispatches actions.app.setBlockContent with content, before dispatching saveBlock', () => { + expect(calls[0]).toEqual([actions.app.setBlockContent(testValue)]); + const saveCall = calls[1][0]; + expect(saveCall.saveBlock).not.toEqual(undefined); + }); + it('dispatches saveBlock with passed content', () => { + expect(calls[1][0].saveBlock.content).toEqual(testValue); + }); + it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => { + dispatch.mockClear(); + const response = 'testRESPONSE'; + calls[1][0].saveBlock.onSuccess(response); + expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response)); + expect(returnToUnit).toHaveBeenCalled(); }); }); }); diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index 9b67968f4..5f2242f77 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -1,6 +1,6 @@ -import { actions } from 'data/redux'; -import { RequestKeys } from 'data/constants/requests'; -import api from 'data/services/lms/api'; +import { actions } from '..'; +import { RequestKeys } from '../../constants/requests'; +import api from '../../services/cms/api'; import * as requests from './requests'; jest.mock('data/services/lms/api', () => ({ diff --git a/src/editors/data/services/cms/utils.test.js b/src/editors/data/services/cms/utils.test.js index d158dac9f..6760e1cd9 100644 --- a/src/editors/data/services/cms/utils.test.js +++ b/src/editors/data/services/cms/utils.test.js @@ -1,10 +1,6 @@ -import queryString from 'query-string'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import * as utils from './utils'; -jest.mock('query-string', () => ({ - stringifyUrl: jest.fn((url, options) => ({ url, options })), -})); jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), })); @@ -26,14 +22,4 @@ describe('cms service utils', () => { expect(utils.post(...args)).toEqual(post(...args)); }); }); - describe('stringifyUrl', () => { - it('forwards url and query to stringifyUrl with options to skip null and ""', () => { - const url = 'here.com'; - const query = { some: 'set', of: 'queryParams' }; - const options = { skipNull: true, skipEmptyString: true }; - expect(utils.stringifyUrl(url, query)).toEqual( - queryString.stringifyUrl({ url, query }, options), - ); - }); - }); }); diff --git a/src/editors/data/store.test.js b/src/editors/data/store.test.js index f44bc0fb7..0a221d679 100644 --- a/src/editors/data/store.test.js +++ b/src/editors/data/store.test.js @@ -3,11 +3,11 @@ import thunkMiddleware from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; import { createLogger } from 'redux-logger'; -import rootReducer, { actions, selectors } from 'data/redux'; +import rootReducer, { actions, selectors } from './redux'; import exportedStore, { createStore } from './store'; -jest.mock('data/redux', () => ({ +jest.mock('./redux', () => ({ __esModule: true, default: 'REDUCER', actions: 'ACTIONS',