diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 1f563c7e7..209e38b23 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,5 +1,4 @@ import packageJsonBuild from '../../package.cordovabuild.json'; -import fakeConfig from './fakeConfig.json'; export const mockCordova = () => { window['cordova'] ||= {}; @@ -101,12 +100,23 @@ export const mockBEMUserCache = (config?) => { }, 100), ); }, + putRWDocument: (key: string, value: any) => { + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + config = value; + rs(); + }, 100), + ); + } + }, getDocument: (key: string, withMetadata?: boolean) => { //returns the config provided as a paramenter to this mock! if (key == 'config/app_ui_config') { return new Promise((rs, rj) => setTimeout(() => { - rs(config || fakeConfig); + if (config) rs(config); + else rs({}); // return empty object if config is not set }, 100), ); } else { diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 0ae025bff..8660fcb82 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -7,8 +7,9 @@ import { import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; +import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockLogger(); global.fetch = (url: string) => diff --git a/www/__tests__/dynamicConfig.test.ts b/www/__tests__/dynamicConfig.test.ts new file mode 100644 index 000000000..12fec433b --- /dev/null +++ b/www/__tests__/dynamicConfig.test.ts @@ -0,0 +1,134 @@ +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockAlert, mockLogger } from '../__mocks__/globalMocks'; +import { getConfig, initByUser } from '../js/config/dynamicConfig'; + +import initializedI18next from '../js/i18nextInit'; +import { storageClear } from '../js/plugin/storage'; +window['i18next'] = initializedI18next; + +mockLogger(); +mockAlert(); +mockBEMUserCache(); + +beforeEach(() => { + // clear all storage and the config document + storageClear({ local: true, native: true }); + window['cordova'].plugins.BEMUserCache.putRWDocument('config/app_ui_config', {}); +}); + +const nrelCommuteConfig = { + version: 1, + server: { + connectUrl: 'https://nrel-commute-openpath.nrel.gov/api/', + aggregate_call_auth: 'user_only', + }, + // ... +}; + +const denverCasrConfig = { + version: 1, + server: { + connectUrl: 'https://denver-casr-openpath.nrel.gov/api/', + aggregate_call_auth: 'user_only', + }, + opcode: { + autogen: true, + subgroups: [ + 'test', + 'qualified-cargo', + 'qualified-regular', + 'standard-cargo', + 'standard-regular', + ], + }, + // ... +}; + +global.fetch = (url: string) => { + return new Promise((rs, rj) => { + if (url.includes('nrel-commute.nrel-op.json')) { + rs({ + ok: true, + json: () => new Promise((rs, rj) => rs(nrelCommuteConfig)), + }); + } else if (url.includes('denver-casr.nrel-op.json')) { + rs({ + ok: true, + json: () => new Promise((rs, rj) => rs(denverCasrConfig)), + }); + } else { + rj(new Error('404 while fetching ' + url)); + } + }) as any; +}; + +describe('dynamicConfig', () => { + const fakeStudyName = 'gotham-city-transit'; + const validStudyNrelCommute = 'nrel-commute'; + const validStudyDenverCasr = 'denver-casr'; + + describe('getConfig', () => { + it('should resolve with null since no config is set yet', async () => { + await expect(getConfig()).resolves.toBeNull(); + }); + it('should resolve with a valid config once initByUser is called for an nrel-commute token', async () => { + const validToken = `nrelop_${validStudyNrelCommute}_user1`; + await initByUser({ token: validToken }); + const config = await getConfig(); + expect(config.server.connectUrl).toBe('https://nrel-commute-openpath.nrel.gov/api/'); + expect(config.joined).toEqual({ + opcode: validToken, + study_name: validStudyNrelCommute, + subgroup: undefined, + }); + }); + + it('should resolve with a valid config once initByUser is called for a denver-casr token', async () => { + const validToken = `nrelop_${validStudyDenverCasr}_test_user1`; + await initByUser({ token: validToken }); + const config = await getConfig(); + expect(config.server.connectUrl).toBe('https://denver-casr-openpath.nrel.gov/api/'); + expect(config.joined).toEqual({ + opcode: validToken, + study_name: validStudyDenverCasr, + subgroup: 'test', + }); + }); + }); + + describe('initByUser', () => { + // fake study (gotham-city-transit) + it('should error if the study is nonexistent', async () => { + const fakeBatmanToken = `nrelop_${fakeStudyName}_batman`; + await expect(initByUser({ token: fakeBatmanToken })).rejects.toThrow(); + }); + + // real study without subgroups (nrel-commute) + it('should error if the study exists but the token is invalid format', async () => { + const badToken1 = validStudyNrelCommute; // doesn't start with nrelop_ + await expect(initByUser({ token: badToken1 })).rejects.toThrow(); + const badToken2 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _ + await expect(initByUser({ token: badToken2 })).rejects.toThrow(); + const badToken3 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _ + await expect(initByUser({ token: badToken3 })).rejects.toThrow(); + }); + it('should return true after successfully storing the config for a valid token', async () => { + const validToken = `nrelop_${validStudyNrelCommute}_user2`; + await expect(initByUser({ token: validToken })).resolves.toBe(true); + }); + + // real study with subgroups (denver-casr) + it('should error if the study uses subgroups but the token has no subgroup', async () => { + const tokenWithoutSubgroup = `nrelop_${validStudyDenverCasr}_user2`; + await expect(initByUser({ token: tokenWithoutSubgroup })).rejects.toThrow(); + }); + it('should error if the study uses subgroups and the token is invalid format', async () => { + const badToken1 = `nrelop_${validStudyDenverCasr}_test_`; // doesn't have user code after last _ + await expect(initByUser({ token: badToken1 })).rejects.toThrow(); + }); + it('should return true after successfully storing the config for a valid token with subgroup', async () => { + const validToken = `nrelop_${validStudyDenverCasr}_test_user2`; + await expect(initByUser({ token: validToken })).resolves.toBe(true); + }); + }); +}); diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index c4fda7dc4..ee5529199 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -8,7 +8,7 @@ import { } from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { getConfig, resetStoredConfig } from '../../www/js/config/dynamicConfig'; +import { getConfig, _test_resetStoredConfig } from '../../www/js/config/dynamicConfig'; import fakeConfig from '../__mocks__/fakeConfig.json'; import initializedI18next from '../js/i18nextInit'; @@ -21,7 +21,7 @@ global.URL = require('url').URL; global.Blob = require('node:buffer').Blob; beforeEach(() => { - resetStoredConfig(); + _test_resetStoredConfig(); }); it('gets the survey config', async () => { diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 842442153..6b1841371 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -9,8 +9,9 @@ import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; +import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockLogger(); global.fetch = (url: string) => diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index bc477daa0..2419100ae 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -4,8 +4,9 @@ import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; import { getConfig } from '../js/config/dynamicConfig'; import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; +import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockLogger(); global.fetch = (url: string) => diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 801e24b07..f684f577d 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -2,6 +2,7 @@ import i18next from 'i18next'; import { displayError, logDebug, logWarn } from '../plugin/logger'; import { fetchUrlCached } from '../services/commHelper'; import { storageClear, storageGet, storageSet } from '../plugin/storage'; +import { AppConfig } from '../types/appConfigTypes'; export const CONFIG_PHONE_UI = 'config/app_ui_config'; export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; @@ -10,12 +11,17 @@ export let storedConfig = null; export let configChanged = false; export const setConfigChanged = (b) => (configChanged = b); -//used test multiple configs, not used outside of test -export const resetStoredConfig = function () { +// used to test multiple configs, not used outside of test +export const _test_resetStoredConfig = () => { storedConfig = null; }; -const _getStudyName = function (connectUrl) { +/** + * @param connectUrl The URL endpoint specified in the config + * @returns The study name (like 'stage' or whatever precedes 'openpath' in the URL), + * or undefined if it can't be determined + */ +function _getStudyName(connectUrl: `https://${string}`) { const orig_host = new URL(connectUrl).hostname; const first_domain = orig_host.split('.')[0]; if (first_domain == 'openpath-stage') { @@ -27,21 +33,30 @@ const _getStudyName = function (connectUrl) { } const study_name = first_domain.substr(0, openpath_index); return study_name; -}; +} -const _fillStudyName = function (config) { - if (!config.name) { - if (config.server) { - config.name = _getStudyName(config.server.connectUrl); - } else { - config.name = 'dev'; - } +/** + * @param config The app config which might be missing 'name' + * @returns Shallow copy of the app config with 'name' filled in if it was missing + */ +function _fillStudyName(config: Partial): AppConfig { + if (config.name) return config as AppConfig; + if (config.server) { + return { ...config, name: _getStudyName(config.server.connectUrl) } as AppConfig; + } else { + return { ...config, name: 'dev' } as AppConfig; } -}; +} -const _backwardsCompatSurveyFill = function (config) { - if (!config.survey_info) { - config.survey_info = { +/** + * @param config The app config which might be missing 'survey_info' + * @returns Shallow copy of the app config with the default 'survey_info' filled in if it was missing + */ +function _fillSurveyInfo(config: Partial): AppConfig { + if (config.survey_info) return config as AppConfig; + return { + ...config, + survey_info: { surveys: { UserProfileSurvey: { formPath: 'json/demo-survey-v2.json', @@ -55,15 +70,25 @@ const _backwardsCompatSurveyFill = function (config) { }, }, 'trip-labels': 'MULTILABEL', - }; - } -}; + }, + } as AppConfig; +} -/* Fetch and cache any surveys resources that are referenced by URL in the config, - as well as the label_options config if it is present. - This way they will be available when the user needs them, and we won't have to - fetch them again unless local storage is cleared. */ -const cacheResourcesFromConfig = (config) => { +/** + * @description Fill in any fields that might be missing from the config ('name', 'survey_info') for + * backwards compatibility with old configs + */ +const _backwardsCompatFill = (config: Partial): AppConfig => + _fillSurveyInfo(_fillStudyName(config)); + +/** + * @description Fetch and cache any surveys resources that are referenced by URL in the config, + * as well as the label_options config if it is present. + * This way they will be available when the user needs them, and we won't have to + * fetch them again unless local storage is cleared. + * @param config The app config + */ +function cacheResourcesFromConfig(config: AppConfig) { if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); @@ -73,50 +98,60 @@ const cacheResourcesFromConfig = (config) => { if (config.label_options) { fetchUrlCached(config.label_options); } -}; +} -const readConfigFromServer = async (label) => { - const config = await fetchConfig(label); - logDebug('Successfully found config, result is ' + JSON.stringify(config).substring(0, 10)); +/** + * @description Fetch the config from the server, fill in any missing fields, and cache any + * resources referenced in the config + * @param studyLabel The 'label' of the study, like 'open-access' or 'dev-emulator-study' + * @returns The filled in app config + */ +async function readConfigFromServer(studyLabel: string) { + const fetchedConfig = await fetchConfig(studyLabel); + logDebug(`Successfully found config, + fetchedConfig = ${JSON.stringify(fetchedConfig).substring(0, 10)}`); + const filledConfig = _backwardsCompatFill(fetchedConfig); + logDebug(`Applied backwards compat fills, + filledConfig = ${JSON.stringify(filledConfig).substring(0, 10)}`); // fetch + cache any resources referenced in the config, but don't 'await' them so we don't block // the config loading process - cacheResourcesFromConfig(config); + cacheResourcesFromConfig(filledConfig); - const connectionURL = config.server ? config.server.connectUrl : 'dev defaults'; - _fillStudyName(config); - _backwardsCompatSurveyFill(config); - logDebug( - 'Successfully downloaded config with version ' + - config.version + - ' for ' + - config.intro.translated_text.en.deployment_name + - ' and data collection URL ' + - connectionURL, - ); - return config; -}; + logDebug(`Successfully read config, returning config with + version = ${filledConfig.version}; + deployment_name = ${filledConfig.intro?.translated_text?.en?.deployment_name}; + connectionURL = ${fetchedConfig.server ? fetchedConfig.server.connectUrl : 'dev defaults'}`); + return filledConfig; +} -const fetchConfig = async (label, alreadyTriedLocal = false) => { - logDebug('Received request to join ' + label); - const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${label}.nrel-op.json`; +/** + * @description Fetch the config for a particular study, either from github, or if in dev mode, from + * localhost:9090 and github if that fails + * @param studyLabel The 'label' of the study, like 'open-access' or 'dev-emulator-study' + * @param alreadyTriedLocal Flag for dev environment, if true, will try to fetch from github + * @returns The fetched app config + */ +async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { + logDebug('Received request to join ' + studyLabel); + const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { logDebug('Fetching config from github'); const r = await fetch(downloadURL); if (!r.ok) throw new Error('Unable to fetch config from github'); - return r.json(); + return r.json(); // TODO: validate, make sure it has required fields } else { logDebug('Running in dev environment, checking for locally hosted config'); try { - const r = await fetch('http://localhost:9090/configs/' + label + '.nrel-op.json'); + const r = await fetch('http://localhost:9090/configs/' + studyLabel + '.nrel-op.json'); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { logDebug('Local config not found'); - return fetchConfig(label, true); + return fetchConfig(studyLabel, true); } } -}; +} /* * We want to support both old style and new style tokens. @@ -130,10 +165,11 @@ const fetchConfig = async (label, alreadyTriedLocal = false) => { * * So let's support two separate functions here - extractStudyName and extractSubgroup */ -function extractStudyName(token) { +function extractStudyName(token: string): string { const tokenParts = token.split('_'); - if (tokenParts.length < 3) { - // all tokens must have at least nrelop_[study name]_... + if (tokenParts.length < 3 || tokenParts.some((part) => part == '')) { + // all tokens must have at least nrelop_[studyname]_[usercode] + // and neither [studyname] nor [usercode] can be blank throw new Error(i18next.t('config.not-enough-parts-old-style', { token: token })); } if (tokenParts[0] != 'nrelop') { @@ -142,7 +178,7 @@ function extractStudyName(token) { return tokenParts[1]; } -function extractSubgroup(token, config) { +function extractSubgroup(token: string, config: AppConfig): string { if (config.opcode) { // new style study, expects token with sub-group const tokenParts = token.split('_'); @@ -186,13 +222,12 @@ function extractSubgroup(token, config) { } /** - * loadNewConfig download and load a new config from the server if it is a differ - * @param {[]} newToken the new token, which includes parts for the study label, subgroup, and user - * @param {} thenGoToIntro whether to go to the intro screen after loading the config - * @param {} [existingVersion=null] if the new config's version is the same, we won't update - * @returns {boolean} boolean representing whether the config was updated or not + * @description Download and load a new config from the server if it is a different version + * @param newToken The new token, which includes parts for the study label, subgroup, and user + * @param existingVersion If the new config's version is the same, we won't update + * @returns boolean representing whether the config was updated or not */ -function loadNewConfig(newToken, existingVersion = null) { +function loadNewConfig(newToken: string, existingVersion?: number): Promise { const newStudyLabel = extractStudyName(newToken); return readConfigFromServer(newStudyLabel) .then((downloadedConfig) => { @@ -224,20 +259,24 @@ function loadNewConfig(newToken, existingVersion = null) { configChanged = true; return true; }) - .catch((storeError) => - displayError(storeError, i18next.t('config.unable-to-store-config')), - ); + .catch((storeError) => { + displayError(storeError, i18next.t('config.unable-to-store-config')); + return Promise.reject(storeError); + }); }) .catch((fetchErr) => { displayError(fetchErr, i18next.t('config.unable-download-config')); + return Promise.reject(fetchErr); }); } -export function initByUser(urlComponents) { +// exported wrapper around loadNewConfig that includes error handling +export function initByUser(urlComponents: { token: string }) { const { token } = urlComponents; try { return loadNewConfig(token).catch((fetchErr) => { displayError(fetchErr, i18next.t('config.unable-download-config')); + return Promise.reject(fetchErr); }); } catch (error) { displayError(error, i18next.t('config.invalid-opcode-format')); @@ -245,26 +284,29 @@ export function initByUser(urlComponents) { } } -export function resetDataAndRefresh() { - // const resetNativePromise = window['cordova'].plugins.BEMUserCache.putRWDocument(CONFIG_PHONE_UI, {}); +/** @description Clears all local and native storage, then triggers a refresh */ +export const resetDataAndRefresh = () => storageClear({ local: true, native: true }).then(() => window.location.reload()); -} -export function getConfig() { +/** + * @returns The app config, either from a cached copy, retrieved from local storage, or retrieved + * from user cache with getDocument() + */ +export function getConfig(): Promise { if (storedConfig) return Promise.resolve(storedConfig); return storageGet(CONFIG_PHONE_UI_KVSTORE).then((config) => { if (config && Object.keys(config).length) { logDebug('Got config from KVStore: ' + JSON.stringify(config)); - storedConfig = config; - return config; + storedConfig = _backwardsCompatFill(config); + return storedConfig; } logDebug('No config found in KVStore, fetching from native storage'); return window['cordova'].plugins.BEMUserCache.getDocument(CONFIG_PHONE_UI, false).then( (config) => { if (config && Object.keys(config).length) { logDebug('Got config from native storage: ' + JSON.stringify(config)); - storedConfig = config; - return config; + storedConfig = _backwardsCompatFill(config); + return storedConfig; } logWarn('No config found in native storage either. Returning null'); return null; diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 206fc77c1..215f3fed8 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { logDebug } from '../plugin/logger'; +import { displayError, logDebug } from '../plugin/logger'; import { ServerConnConfig } from '../types/appConfigTypes'; /** @@ -12,12 +12,16 @@ export async function fetchUrlCached(url) { logDebug(`fetchUrlCached: found cached data for url ${url}, returning`); return Promise.resolve(stored); } - logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`); - const response = await fetch(url); - const text = await response.text(); - localStorage.setItem(url, text); - logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); - return text; + try { + logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`); + const response = await fetch(url); + const text = await response.text(); + localStorage.setItem(url, text); + logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); + return text; + } catch (e) { + displayError(e, `While fetching ${url}`); + } } export function getRawEntries( diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 1a2e50722..2f258b575 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -19,7 +19,7 @@ export type EnketoSurveyConfig = { [surveyName: string]: { formPath: string; labelTemplate: { [lang: string]: string }; - labelVars: { [activity: string]: { [key: string]: string; type: string } }; + labelVars?: { [activity: string]: { [key: string]: string; type: string } }; version: number; compatibleWith: number; dataKey?: string;