diff --git a/.env.sample b/.env.sample index 0e9d6085d..2e43daeb5 100644 --- a/.env.sample +++ b/.env.sample @@ -27,11 +27,14 @@ OAUTH_CONSUMER_KEY= OAUTH_CONSUMER_SECRET= # require additional auth before osm auth -PREAUTH_URL= -PREAUTH_NAME= +# PREAUTH_URL= +# PREAUTH_NAME= # geocoding via opencage OPENCAGE_KEY= # set limit for downloading mapbox tiles. 6000 is default and compliant with ToS -MAPBOX_OFFLINE_TILE_DOWNLOAD_LIMIT=6000 \ No newline at end of file +MAPBOX_OFFLINE_TILE_DOWNLOAD_LIMIT=6000 + +# optional observe api endpoint to send photos and traces to +# OBSERVE_API_URL= diff --git a/__tests__/actions/__snapshots__/trace.test.js.snap b/__tests__/actions/__snapshots__/trace.test.js.snap index 4d112e21f..ff34ad410 100644 --- a/__tests__/actions/__snapshots__/trace.test.js.snap +++ b/__tests__/actions/__snapshots__/trace.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`test trace actions should start trace correctly 1`] = ` +exports[`test trace sync actions should start trace correctly 1`] = ` Array [ Object { "type": "TRACE_START", @@ -45,3 +45,173 @@ Array [ }, ] `; + +exports[`trace upload / async actions should add error to the trace for an error response 1`] = ` +Array [ + Object { + "id": "id-1", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "error": [Error: Unknown error], + "id": "id-1", + "type": "TRACE_UPLOAD_FAILED", + }, +] +`; + +exports[`trace upload / async actions should add error to the trace for an error response 2`] = ` +Array [ + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[1,11,21],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[1,1],[2,0],[3,-1]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; + +exports[`trace upload / async actions should not upload non-pending traces 1`] = ` +Array [ + Object { + "id": "id-1", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "newId": "fakeid-1", + "oldId": "id-1", + "type": "TRACE_UPLOADED", + }, +] +`; + +exports[`trace upload / async actions should not upload non-pending traces 2`] = ` +Array [ + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[1,11,21],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[1,1],[2,0],[3,-1]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; + +exports[`trace upload / async actions should not upload uploading traces 1`] = ` +Array [ + Object { + "id": "id-2", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "newId": "fakeid-2", + "oldId": "id-2", + "type": "TRACE_UPLOADED", + }, +] +`; + +exports[`trace upload / async actions should not upload uploading traces 2`] = ` +Array [ + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[2,12,22],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[2,2],[3,1],[4,0]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; + +exports[`trace upload / async actions should upload a single trace 1`] = ` +Array [ + Object { + "id": "id-1", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "newId": "fakeid", + "oldId": "id-1", + "type": "TRACE_UPLOADED", + }, +] +`; + +exports[`trace upload / async actions should upload a single trace 2`] = ` +Array [ + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[1,11,21],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[1,1],[2,0],[3,-1]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; + +exports[`trace upload / async actions should upload multiple pending traces 1`] = ` +Array [ + Object { + "id": "id-1", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "newId": "fakeid-1", + "oldId": "id-1", + "type": "TRACE_UPLOADED", + }, + Object { + "id": "id-2", + "type": "TRACE_UPLOAD_STARTED", + }, + Object { + "newId": "fakeid-2", + "oldId": "id-2", + "type": "TRACE_UPLOADED", + }, +] +`; + +exports[`trace upload / async actions should upload multiple pending traces 2`] = ` +Array [ + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[1,11,21],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[1,1],[2,0],[3,-1]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + Array [ + "http://localhost:3000/traces", + Object { + "body": "{\\"tracejson\\":{\\"type\\":\\"Feature\\",\\"properties\\":{\\"timestamps\\":[2,12,22],\\"description\\":\\"\\"},\\"geometry\\":{\\"type\\":\\"LineString\\",\\"coordinates\\":[[2,2],[3,1],[4,0]]}}}", + "headers": Object { + "Authorization": "abcd", + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; diff --git a/__tests__/actions/trace.test.js b/__tests__/actions/trace.test.js index f2ece2b00..0c7df7992 100644 --- a/__tests__/actions/trace.test.js +++ b/__tests__/actions/trace.test.js @@ -1,4 +1,4 @@ -/* global jest, it, expect, describe */ +/* global jest, it, expect, describe, fetch */ import configureStore from 'redux-mock-store' import thunk from 'redux-thunk' @@ -8,8 +8,10 @@ import { pauseTrace, unpauseTrace, startSavingTrace, - discardTrace + discardTrace, + uploadPendingTraces } from '../../app/actions/traces' +import { getMockTrace } from '../test-utils' const middlewares = [thunk] const mockStore = configureStore(middlewares) @@ -45,6 +47,22 @@ jest.mock('../../app/services/trace', () => { } }) +// This is required because the observe API service calls the store directly +// to get the token. +jest.mock('../../app/utils/store', () => { + return { + store: { + getState: () => { + return { + observeApi: { + token: 'abcd' + } + } + } + } + } +}) + const getMockCurrentTrace = function () { return { type: 'Feature', @@ -59,7 +77,29 @@ const getMockCurrentTrace = function () { } } -describe('test trace actions', () => { +const getMockTracePostResponse = function (m, id) { + return { + type: 'Feature', + properties: { + id, + timestamps: [ + m, + m + 10, + m + 20 + ] + }, + geometry: { + type: 'LineString', + coordinates: [ + [m, m], + [m + 1, m - 1], + [m + 2, m - 2] + ] + } + } +} + +describe('test trace sync actions', () => { it('should start trace correctly', () => { const store = mockStore({ traces: { @@ -123,3 +163,96 @@ describe('test trace actions', () => { expect(actions[0].type).toEqual('TRACE_DISCARD') }) }) + +describe('trace upload / async actions', () => { + it('should upload a single trace', async () => { + const store = mockStore({ + traces: { + traces: [ + getMockTrace(1) + ] + } + }) + fetch.resetMocks() + fetch.once(JSON.stringify(getMockTracePostResponse(1, 'fakeid'))) + await store.dispatch(uploadPendingTraces()) + const actions = store.getActions() + expect(actions).toMatchSnapshot() + expect(fetch.mock.calls).toMatchSnapshot() + }) + + it('should upload multiple pending traces', async () => { + const store = mockStore({ + traces: { + traces: [ + getMockTrace(1), + getMockTrace(2) + ] + } + }) + fetch.resetMocks() + fetch.once(JSON.stringify(getMockTracePostResponse(1, 'fakeid-1'))) + .once(JSON.stringify(getMockTracePostResponse(2, 'fakeid-2'))) + await store.dispatch(uploadPendingTraces()) + const actions = store.getActions() + expect(actions).toMatchSnapshot() + expect(fetch.mock.calls).toMatchSnapshot() + }) + + it('should not upload non-pending traces', async () => { + const trace1 = getMockTrace(1) + const trace2 = getMockTrace(2) + trace2.status = 'uploaded' + const store = mockStore({ + traces: { + traces: [ + trace1, + trace2 + ] + } + }) + fetch.resetMocks() + fetch.once(JSON.stringify(getMockTracePostResponse(1, 'fakeid-1'))) + await store.dispatch(uploadPendingTraces()) + const actions = store.getActions() + expect(actions).toMatchSnapshot() + expect(fetch.mock.calls).toMatchSnapshot() + }) + + it('should not upload uploading traces', async () => { + const trace1 = getMockTrace(1) + trace1.status = 'uploading' + const trace2 = getMockTrace(2) + const store = mockStore({ + traces: { + traces: [ + trace1, + trace2 + ] + } + }) + fetch.resetMocks() + fetch.once(JSON.stringify(getMockTracePostResponse(2, 'fakeid-2'))) + await store.dispatch(uploadPendingTraces()) + const actions = store.getActions() + expect(actions).toMatchSnapshot() + expect(fetch.mock.calls).toMatchSnapshot() + }) + + it('should add error to the trace for an error response', async () => { + const trace1 = getMockTrace(1) + const store = mockStore({ + traces: { + traces: [ + trace1 + ] + } + }) + fetch.resetMocks() + fetch.once(JSON.stringify({ 'message': 'Unknown error' }), { status: 500 }) + await store.dispatch(uploadPendingTraces()) + const actions = store.getActions() + expect(actions).toMatchSnapshot() + expect(fetch.mock.calls).toMatchSnapshot() + }) +}) diff --git a/__tests__/reducers/traces.test.js b/__tests__/reducers/traces.test.js index e0de7348d..a0ed58362 100644 --- a/__tests__/reducers/traces.test.js +++ b/__tests__/reducers/traces.test.js @@ -1,6 +1,8 @@ /* global describe, it, expect */ import reducer from '../../app/reducers/traces' +import { getMockTrace } from '../test-utils' +import { ObserveAPIError } from '../../app/utils/errors' const initialState = { currentTrace: null, @@ -184,7 +186,8 @@ describe('test for traces reducer', () => { traces: [ { id: 'observe-hauptbanhof', - pending: true, + status: 'pending', + errors: [], geojson: expectedTraceGeoJSON } ] @@ -219,3 +222,69 @@ describe('test for traces reducer', () => { expect(newState.saving).toEqual(false) }) }) + +describe('tests for upload trace actions', () => { + it('should handle TRACE_UPLOAD_STARTED action correctly', () => { + const mockTrace1 = getMockTrace(1) + const mockTrace2 = getMockTrace(2) + const state = { + ...initialState, + traces: [ + mockTrace1, + mockTrace2 + ] + } + const action = { + type: 'TRACE_UPLOAD_STARTED', + id: mockTrace1.id + } + const newState = reducer(state, action) + expect(newState.traces[0].status).toEqual('uploading') + expect(newState.traces[1].status).toEqual('pending') + }) + + it('should handle TRACE_UPLOADED action correctly', () => { + const mockTrace1 = getMockTrace(1) + mockTrace1.uploading = true + const mockTrace2 = getMockTrace(2) + const state = { + ...initialState, + traces: [ + mockTrace1, + mockTrace2 + ] + } + const action = { + type: 'TRACE_UPLOADED', + oldId: mockTrace1.id, + newId: 'fakeid' + } + const newState = reducer(state, action) + expect(newState.traces[0].id).toEqual('fakeid') + expect(newState.traces[0].status).toEqual('uploaded') + expect(newState.traces[0].geojson.properties.id).toEqual('fakeid') + }) + + it('should handle TRACE_UPLOAD_FAILED action correctly', () => { + const mockTrace1 = getMockTrace(1) + mockTrace1.uploading = true + const mockTrace2 = getMockTrace(2) + const state = { + ...initialState, + traces: [ + mockTrace1, + mockTrace2 + ] + } + const action = { + type: 'TRACE_UPLOAD_FAILED', + id: mockTrace1.id, + error: new ObserveAPIError('fake', 404) + } + const newState = reducer(state, action) + expect(newState.traces[0].errors.length).toEqual(1) + expect(newState.traces[0].status).toEqual('pending') + expect(newState.traces[0].errors[0].message).toEqual('fake') + expect(newState.traces[0].errors[0].status).toEqual(404) + }) +}) diff --git a/__tests__/setup/mock.js b/__tests__/setup/mock.js index c4de8f1ac..a748f5caf 100644 --- a/__tests__/setup/mock.js +++ b/__tests__/setup/mock.js @@ -277,7 +277,8 @@ jest.mock('react-native-geolocation-service', () => ({ jest.mock('react-native-config', () => ({ OPENCAGE_KEY: 123, - API_URL: 'http://example.com' + API_URL: 'http://example.com', + OBSERVE_API_URL: 'http://localhost:3000' })) jest.mock('../../app/utils/get-random-id', () => { diff --git a/__tests__/test-utils.js b/__tests__/test-utils.js index 531f42fb9..f5c051fc0 100644 --- a/__tests__/test-utils.js +++ b/__tests__/test-utils.js @@ -16,3 +16,33 @@ export function getFeature ( geometry } } + +/** + * + * @param {Number} m - used to construct id, timestamps, coords + */ +export function getMockTrace (m) { + return { + id: `id-${m}`, + status: 'pending', + errors: [], + geojson: { + type: 'Feature', + properties: { + timestamps: [ + m, + m + 10, + m + 20 + ] + }, + geometry: { + type: 'LineString', + coordinates: [ + [m, m], + [m + 1, m - 1], + [m + 2, m - 2] + ] + } + } + } +} diff --git a/app/actions/observeApi.js b/app/actions/observeApi.js index 3edb2b1f5..93b78dcad 100644 --- a/app/actions/observeApi.js +++ b/app/actions/observeApi.js @@ -1,4 +1,5 @@ import * as types from './actionTypes' +import * as api from '../services/observe-api' export function setObserveAPIToken (token) { return { @@ -6,3 +7,9 @@ export function setObserveAPIToken (token) { token } } + +export function getProfile () { + return async dispatch => { + await dispatch(api.getProfile()) + } +} diff --git a/app/actions/traces.js b/app/actions/traces.js index ae9c280ea..f32933520 100644 --- a/app/actions/traces.js +++ b/app/actions/traces.js @@ -1,5 +1,6 @@ import * as types from './actionTypes' import traceService from '../services/trace' +import * as api from '../services/observe-api' export function startTrace () { return (dispatch, getState) => { @@ -25,13 +26,54 @@ export function unpauseTrace () { } export function endTrace (description = '') { - return (dispatch, getState) => { + return async (dispatch, getState) => { const { watcher, currentTrace } = getState().traces if (!currentTrace) { console.error('endTrace called with no current trace') return } traceService.endTrace(dispatch, watcher, description) + await dispatch(uploadPendingTraces()) + } +} + +export function uploadPendingTraces () { + return async (dispatch, getState) => { + console.log('called upload pending traces') + const { traces } = getState().traces + const pendingTraces = traces.filter(t => t.status === 'pending') + for (let trace of pendingTraces) { + dispatch(startUploadingTrace(trace.id)) + try { + const newId = await api.uploadTrace(trace) + dispatch(uploadedTrace(trace.id, newId)) + } catch (e) { + dispatch(uploadTraceFailed(trace.id, e)) + } + } + } +} + +function startUploadingTrace (id) { + return { + type: types.TRACE_UPLOAD_STARTED, + id + } +} + +function uploadedTrace (oldId, newId) { + return { + type: types.TRACE_UPLOADED, + oldId, + newId + } +} + +function uploadTraceFailed (id, error) { + return { + type: types.TRACE_UPLOAD_FAILED, + id, + error } } diff --git a/app/reducers/index.js b/app/reducers/index.js index 99b3bd95e..c06ecb0ea 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -46,6 +46,14 @@ const tracesPersistConfig = { ] } +const observeApiPersistConfig = { + key: 'observeApi', + storage, + whitelist: [ + 'token' + ] +} + const photosPersistConfig = { key: 'photos', storage, @@ -64,7 +72,7 @@ const rootReducer = combineReducers({ network, notification: NotificationReducer, traces: persistReducer(tracesPersistConfig, TracesReducer), - observeApi: ObserveAPIReducer, + observeApi: persistReducer(observeApiPersistConfig, ObserveAPIReducer), photos: persistReducer(photosPersistConfig, CameraReducer) }) diff --git a/app/reducers/traces.js b/app/reducers/traces.js index 1a1cf6339..91057bc03 100644 --- a/app/reducers/traces.js +++ b/app/reducers/traces.js @@ -5,6 +5,8 @@ import { } from '../utils/traces' import getRandomId from '../utils/get-random-id' +import _cloneDeep from 'lodash.clonedeep' +import _findIndex from 'lodash.findindex' const initialState = { currentTrace: null, @@ -52,7 +54,8 @@ export default function (state = initialState, action) { const traceId = getRandomId() const newTrace = { id: traceId, - pending: true, + status: 'pending', + errors: [], geojson: { ...state.currentTrace, properties: { @@ -104,6 +107,39 @@ export default function (state = initialState, action) { watcher: action.watcher } } + + case types.TRACE_UPLOAD_STARTED: { + const traces = _cloneDeep(state.traces) + const index = _findIndex(state.traces, t => t.id === action.id) + traces[index].status = 'uploading' + return { + ...state, + traces + } + } + + case types.TRACE_UPLOADED: { + const traces = _cloneDeep(state.traces) + const index = _findIndex(state.traces, t => t.id === action.oldId) + traces[index].status = 'uploaded' + traces[index].id = action.newId + traces[index].geojson.properties.id = action.newId + return { + ...state, + traces + } + } + + case types.TRACE_UPLOAD_FAILED: { + const traces = _cloneDeep(state.traces) + const index = _findIndex(state.traces, t => t.id === action.id) + traces[index].status = 'pending' + traces[index].errors.push(action.error) + return { + ...state, + traces + } + } } return state } diff --git a/app/screens/Settings.js b/app/screens/Settings.js index 5a30629db..433738700 100644 --- a/app/screens/Settings.js +++ b/app/screens/Settings.js @@ -6,6 +6,7 @@ import Config from 'react-native-config' import { purgeCache, purgeStore, purgeCookies } from '../actions/about' import { purgeAllEdits } from '../actions/edit' import { startTrace, endTrace } from '../actions/traces' +import { getProfile } from '../actions/observeApi' import Icon from '../components/Collecticons' import Header from '../components/Header' import PageWrapper from '../components/PageWrapper' @@ -110,6 +111,13 @@ class Settings extends React.Component { color={colors.primary} /> + +