diff --git a/common/.babelrc b/common/.babelrc new file mode 100644 index 0000000..86c445f --- /dev/null +++ b/common/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react"] +} diff --git a/common/actionTypes/todos.js b/common/actionTypes/todos.js index be66cba..64d33e6 100644 --- a/common/actionTypes/todos.js +++ b/common/actionTypes/todos.js @@ -20,3 +20,4 @@ export const setVisibilityFilter = buildTodosActionType('setVisibilityFilter'); export const getTodos = buildTodosActionType('getTodos'); export const getTodosSuccess = buildTodosActionType('getTodosSuccess'); export const getTodosError = buildTodosActionType('getTodosError'); +export const timeoutTodo = buildTodosActionType('timeoutTodo'); diff --git a/common/actions/todos.js b/common/actions/todos.js index febf8eb..6731e5b 100644 --- a/common/actions/todos.js +++ b/common/actions/todos.js @@ -20,3 +20,4 @@ export const setVisibilityFilter = createAction(todos.setVisibilityFilter); export const getTodos = createAction(todos.getTodos); export const getTodosSuccess = createAction(todos.getTodosSuccess); export const getTodosError = createAction(todos.getTodosError); +export const timeoutTodo = createAction(todos.timeoutTodo); diff --git a/common/api/todos.js b/common/api/todos.js index 2aa6070..7decd96 100644 --- a/common/api/todos.js +++ b/common/api/todos.js @@ -5,46 +5,77 @@ import type { descriptionAndId, toggle } from 'todosIndex/types'; // TODO: Add an environment variable management setup that allows env vars // to be used with both React and React Native - + const apiConfig = { remoteEndpoint: 'http://localhost:3000' }; export const todosScope = (path: ?string) => { return `${apiConfig.remoteEndpoint}/api/v1/todos${path || ''}`; } -export const addRemoteEndpoint = (remoteEndpoint: string) => { +export const addRemoteEndpoint = async (remoteEndpoint: string) => { apiConfig.remoteEndpoint = remoteEndpoint; //TODO: Check if the slash exists and remove it } +export const timeoutDelay = (time: number) => { + return new Promise(resolve => { + setTimeout(resolve(false), time); + }); +} + // /api/v1/todos -export const addTodo = (description: string, completed: boolean = false) => { - const url = todosScope(); - const todoParams = { todo: { description, completed } }; - return apiCall.post({ url, data: todoParams }); +export const addTodo = async (description: string, completed: boolean = false) => { + try { + const url = todosScope(); + const todoParams = { todo: { description, completed } }; + const result = await apiCall.post({ url, data: todoParams }); + return ({ result }); + } catch (error) { + return ({ error }); + } }; // /api/v1/todos -export const editTodo = (todo: descriptionAndId) => { - const url = todosScope(`/${todo.id}`); - const todoParams = { todo: { description: todo.description } }; - return apiCall.put({ url, data: todoParams }); +export const editTodo = async (todo: descriptionAndId) => { + try { + const url = todosScope(`/${todo.id}`); + const todoParams = { todo: { description: todo.description } }; + const result = await apiCall.put({ url, data: todoParams }); + return ({ result }); + } catch (error) { + return ({ error }); + } }; // /api/v1/todos/:todo_id -export const removeTodo = (todoId: number) => { - const url = todosScope(`/${todoId}`); - return apiCall.delete({ url }); +export const removeTodo = async (todoId: number) => { + try { + const url = todosScope(`/${todoId}`); + const result = await apiCall.delete({ url }); + retrun ({ result }); + } catch (error) { + return ({ error }); + } }; // /api/v1/todos -export const toggleTodo = (todo: toggle) => { - const url = todosScope(`/${todo.id}`); - const todoParams = { todo: { completed: todo.completed } }; - return apiCall.put({ url, data: todoParams }); +export const toggleTodo = async (todo: toggle) => { + try { + const url = todosScope(`/${todo.id}`); + const todoParams = { todo: { completed: todo.completed } }; + const result = await apiCall.put({ url, data: todoParams }); + return ({ result }); + } catch (error) { + return ({ error }); + } }; // /api/v1/todos -export const getTodos = () => { - const url = todosScope('/'); - return apiCall.get({ url }); +export const getTodos = async () => { + try { + const url = todosScope('/'); + const result = await apiCall.get({ url }); + return ({ result }); + } catch (error) { + return ({ error }); + } } diff --git a/common/junit.xml b/common/junit.xml index 4c41c46..ef97e4b 100644 --- a/common/junit.xml +++ b/common/junit.xml @@ -1,28 +1,12 @@ - - + + - - - - - - - - - - - - - - - - - + - - + + @@ -33,19 +17,7 @@ - - - - - - - - - - - - - + @@ -56,16 +28,42 @@ - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/libs/utils/api/index.js b/common/libs/utils/api/index.js index 0172b70..a77c1f2 100644 --- a/common/libs/utils/api/index.js +++ b/common/libs/utils/api/index.js @@ -83,13 +83,13 @@ function parseImmutableData(data: any) { function checkResponseStatus(response: Response) { if (response.ok) return response; - - return response.json().then(errData => { - const isBadCsrfToken = response.status === 401 && errData.message === 'Bad Authenticity Token'; - if (isBadCsrfToken && IS_BROWSER) window.location.reload(); - const error = new ApiError(response.statusText, errData, response.status); - throw error; - }); + return response.json() + .then(errData => { + const isBadCsrfToken = response.status === 401 && errData.message === 'Bad Authenticity Token'; + if (isBadCsrfToken && IS_BROWSER) window.location.reload(); + const error = new ApiError(response.statusText, errData, response.status); + throw error; + }); } function parseResponse(response: Response) { diff --git a/common/libs/utils/api/index.test.js b/common/libs/utils/api/index.test.js index 5891111..e85cd32 100644 --- a/common/libs/utils/api/index.test.js +++ b/common/libs/utils/api/index.test.js @@ -3,6 +3,7 @@ import { buildUrl, parseRawParams, buildReqUrl, + callApi } from './index'; const todoParams = { todo: { description: 'this is a todo.' } }; @@ -56,4 +57,26 @@ describe('libs/utils/api', () => { expect(actual).toMatchObject(expected); }); }); + + describe('callApi', () => { + it('handles 400 error with an exception', async () => { + const url = 'http://fakeurl'; + fetch.mockResponseOnce(JSON.stringify([{description: "add a todo"}]), {status: 400}); + try { + const resp = await callApi('GET', { url }); + } catch (error) { + expect.anything(); + } + }); + + it('handles 500 error with an exception', async () => { + const url = 'http://fakeurl'; + fetch.mockResponseOnce(JSON.stringify([{description: "add a todo"}]), {status: 500}); + try { + const resp = await callApi('GET', { url }); + } catch (error) { + expect.anything(); + } + }); + }); }); diff --git a/common/package.json b/common/package.json index 82fdc2f..b4b20da 100644 --- a/common/package.json +++ b/common/package.json @@ -21,13 +21,16 @@ "react-redux": "^5.0.3", "redux": "^3.6.0", "redux-actions": "^2.0.2", - "redux-saga": "^0.14.6", - "webpack": "^2.4.1" + "redux-saga": "^0.14.6" }, "devDependencies": { + "babel-jest": "19.0.0", + "babel-plugin-flow-react-proptypes": "^0.21.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "babylon": "^6.16.1", "eslint": "^3.17.1", "eslint-config-shakacode": "^14.1.1", - "eslint-import-resolver-webpack": "^0.8.0", "eslint-plugin-flowtype": "^2.30.3", "eslint-plugin-import": "^2.2.0", "eslint-plugin-jest": "^19.0.1", @@ -37,10 +40,15 @@ "flow-bin": "^0.41.0", "flow-typed": "2.0.0", "jest": "^19.0.2", + "jest-fetch-mock": "^1.1.1", "jest-junit": "^1.3.0" }, "jest": { "verbose": true, - "testResultsProcessor": "./node_modules/jest-junit" + "automock": false, + "testResultsProcessor": "./node_modules/jest-junit", + "setupFiles": [ + "./test/setupJest.js" + ] } } diff --git a/common/sagas/index.js b/common/sagas/index.js index 1890c47..6396548 100644 --- a/common/sagas/index.js +++ b/common/sagas/index.js @@ -1,5 +1,12 @@ // @flow -import { call, put, fork, takeEvery } from 'redux-saga/effects'; +import { + call, + put, + fork, + race, + takeEvery +} from 'redux-saga/effects'; +import { delay } from 'redux-saga'; import type { putEffect, IOEffect } from 'redux-saga/effects'; import * as api from '../api/todos'; @@ -14,6 +21,7 @@ import { removeTodo as removeTodoActionType, toggleTodo as toggleTodoActionType, getTodos as getTodosActionType, + timeoutTodo as timeoutTodoType } from '../actionTypes/todos'; import * as todosActions from '../actions/todos'; import type { @@ -24,58 +32,94 @@ import type { getTodosPayload, } from '../types'; + +// TODO: IMO this is a really long timeout but on my machine this is the min +// timeout that guarantees the API always has a chance to complete! +// This should be in the ENV settings also. + +const API_TIMEOUT = 5000; + //TODO: Add a logging hook here so that the native app and the React app // have a way to log out what happened if needed. -export function* addTodo({ payload }: stringPayload): Generator { - try { - const response = yield call(api.addTodo, payload); - yield put(todosActions.addTodoSuccess(normalizeObjectToMap(response))); - } catch (e) { - yield put(todosActions.addTodoError()); +export function* raceCallApi({ + apiCall, + payload, + successAction, + failAction, + normalizer +}) { + const { response, timeout } = yield race({ + response: call(apiCall, payload), + timeout: call(delay, API_TIMEOUT) + }); + + if(response) { + if(response.result) { + if(normalizer) { + yield put(successAction(normalizer(response.result))); + } else { + yield put(successAction(response.result)); + } + } else { + yield put(failAction(response.error)); + } + } else { + yield put(todosActions.timeoutTodo()); } } +export function* addTodo({ payload }: stringPayload): Generator { + yield call(raceCallApi, + { + apiCall: api.addTodo, + payload, + successAction: todosActions.addTodoSuccess, + failAction: todosActions.addTodoError, + normalizer: normalizeObjectToMap + }); +} + export function* editTodo({ payload }: descriptionPayload): Generator { - try { - const response = yield call(api.editTodo, payload); - yield put(todosActions.editTodoSuccess(normalizeObjectToMap(response))); - } catch (e) { - yield put(todosActions.editTodoError()); - } + yield call(raceCallApi, + { + apiCall: api.editTodo, + payload, + successAction: todosActions.editTodoSuccess, + failAction: todosActions.editTodoError, + normalizer: normalizeObjectToMap + }); } export function* removeTodo({ payload }: numberPayload): Generator { - try { - const response = yield call(api.removeTodo, payload); - yield put(todosActions.removeTodoSuccess(response)); - } catch (e) { - yield put(todosActions.removeTodoError()); - } + yield call(raceCallApi, + { + apiCall: api.removeTodo, + payload, + successAction: todosActions.removeTodoSuccess, + failAction: todosActions.removeTodoError + }); } export function* toggleTodo({ payload }: togglePayload): Generator { - try { - const response = yield call(api.toggleTodo, payload); - yield put(todosActions.toggleTodoSuccess(normalizeObjectToMap(response))); - } catch (e) { - yield put(todosActions.toggleTodoError()); - } + yield call(raceCallApi, + { + apiCall: api.toggleTodo, + payload, + successAction: todosActions.toggleTodoSuccess, + failAction: todosActions.toggleTodoError, + normalizer: normalizeObjectToMap + }); } -// TODO: Clean up exception handling here - this can have bad side effects -// with React, per: https://github.com/redux-saga/redux-saga/issues/521 -// Recommended to catch API exceptions in API and then return an error -// object here, also use race() to deal with flaky cellular and browser data - export function* getTodos(): Generator { - try { - const response = yield call(api.getTodos); - objectMap = normalizeArrayToMap(response); - yield put(todosActions.getTodosSuccess(objectMap)); - } catch (e) { - yield put(todosActions.getTodosError()); - } + yield call(raceCallApi, + { + apiCall: api.getTodos, + successAction: todosActions.getTodosSuccess, + failAction: todosActions.getTodosError, + normalizer: normalizeArrayToMap + }); } function* addTodoSaga() { diff --git a/common/sagas/sagas.test.js b/common/sagas/sagas.test.js index c18ffb1..e0e64ae 100644 --- a/common/sagas/sagas.test.js +++ b/common/sagas/sagas.test.js @@ -1,94 +1,52 @@ // @flow -import { call, put } from 'redux-saga/effects'; +import { call, put, race } from 'redux-saga/effects'; +import { delay } from 'redux-saga'; import * as api from '../api/todos'; -import { normalizeObjectToMap } from '../libs/utils/normalizr'; +import { + normalizeObjectToMap, + normalizeArrayToMap, + } from '../libs/utils/normalizr'; import * as sagas from './index'; import * as todosActions from '../actions/todos'; -// TODO: Fix all the tests in common - -describe('addTodo Saga', () => { +describe('raceCallApi Generator Function', () => { it('handles async responses', () => { - const description = 'todo description'; - - const action = todosActions.addTodo(description); - const generator = sagas.addTodo(action); + const action = todosActions.getTodos(); + fetch.mockResponseOnce(JSON.stringify([{description: "add a todo"}])); + const generator = sagas.raceCallApi({ + apiCall: api.getTodos, + successAction: todosActions.getTodosSuccess, + failAction: todosActions.getTodosError, + normalizer: normalizeArrayToMap, + }); let nextGen = generator.next(); - expect(nextGen.value).toEqual(call(api.addTodo, description)); - - const result = { - id: 1, - description: 'todo', - completed: false, - created_at: 'earlier', - updated_at: 'also earlier', - }; - nextGen = generator.next(result); - expect(nextGen.value).toEqual(put(todosActions.addTodoSuccess(normalizeObjectToMap(result)))); + expect(nextGen.value).toEqual(race({ + response: call(api.getTodos, undefined), + timeout: call(delay, 5000), + })); }); -}); - -describe('editTodo Saga', () => { - it('handles async responses', () => { - const description = 'todo description'; - const id = 'todoId'; - const payload = { description, id }; - - const action = todosActions.editTodo(payload); - const generator = sagas.editTodo(action); - - let nextGen = generator.next(); - expect(nextGen.value).toEqual(call(api.editTodo, payload)); - const result = { - id: 1, - description: 'todo', - completed: false, - created_at: 'earlier', - updated_at: 'also earlier', + // TODO: Right now the timeout delay is hard coded to 5 sec but this needs + // to be thought out a little more + it('handles timed out responses (over 6000 ms)', () => { + const action = todosActions.getTodos(); + const delay = () => { return new Promise((resolve) => { + setTimeout(() => resolve({url: '/delayed', delay: 500}), 6000); + }) }; - nextGen = generator.next(result); - expect(nextGen.value).toEqual(put(todosActions.editTodoSuccess(normalizeObjectToMap(result)))); - }); -}); - -describe('removeTodo Saga', () => { - it('handles async responses', () => { - const payload = '1'; - const action = todosActions.removeTodo(payload); - const generator = sagas.removeTodo(action); - - let nextGen = generator.next(); - expect(nextGen.value).toEqual(call(api.removeTodo, payload)); - - const result = 'data'; - nextGen = generator.next(result); - expect(nextGen.value).toEqual(put(todosActions.removeTodoSuccess(result))); - }); -}); - -describe('toggleTodo Saga', () => { - it('handles async responses', () => { - const id = 'todoId'; - const payload = { id, complete: true }; - - const action = todosActions.toggleTodo(payload); - const generator = sagas.toggleTodo(action); + fetch.mockResponseOnce(JSON.stringify([{description: "add a todo"}]), delay); + const generator = sagas.raceCallApi({ + apiCall: api.getTodos, + successAction: todosActions.getTodosSuccess, + failAction: todosActions.getTodosError, + normalizer: normalizeArrayToMap, + }); let nextGen = generator.next(); - expect(nextGen.value).toEqual(call(api.toggleTodo, payload)); - - const result = { - id: 1, - description: 'todo', - completed: false, - created_at: 'earlier', - updated_at: 'also earlier', - }; - nextGen = generator.next(result); - expect(nextGen.value).toEqual(put(todosActions.toggleTodoSuccess(normalizeObjectToMap(result)))); + let nextGen1 = generator.next(nextGen.value); + expect(nextGen1.value).toEqual(put(todosActions.timeoutTodo())); }); }); diff --git a/common/test/setupJest.js b/common/test/setupJest.js new file mode 100644 index 0000000..c158ca4 --- /dev/null +++ b/common/test/setupJest.js @@ -0,0 +1 @@ +global.fetch = require('jest-fetch-mock'); diff --git a/todo-app-react-native/package.json b/todo-app-react-native/package.json index d51bc16..3d61daf 100644 --- a/todo-app-react-native/package.json +++ b/todo-app-react-native/package.json @@ -12,15 +12,14 @@ "immutable": "^3.8.1", "lodash": "^4.17.4", "normalizr": "^3.2.2", - "react": "15.4.2", - "react-native": "0.41.2", + "react": "^15.4.2", + "react-native": "^0.41.2", "react-native-vector-icons": "^4.0.0", "react-redux": "^5.0.3", "redux": "^3.6.0", "redux-actions": "^2.0.2", "redux-logger": "^2.8.1", - "redux-saga": "^0.14.6", - "redux-thunk": "^2.2.0" + "redux-saga": "^0.14.6" }, "devDependencies": { "babel-jest": "19.0.0", @@ -38,9 +37,12 @@ "eslint-plugin-react-native": "^2.3.1", "flow-bin": "^0.41.0", "jest": "19.0.2", + "jest-junit": "^1.3.0", "react-test-renderer": "15.4.2" }, "jest": { - "preset": "react-native" + "preset": "react-native", + "verbose": true, + "testResultsProcessor": "./node_modules/jest-junit" } }