Skip to content

Commit

Permalink
Merge pull request #1040 from JGreenlee/rewrite-services-sept2023
Browse files Browse the repository at this point in the history
✏️ Rewrite KVStore, ClientStats, and CommHelper
  • Loading branch information
shankari authored Oct 13, 2023
2 parents 68183e7 + e546d73 commit 5f530f5
Show file tree
Hide file tree
Showing 36 changed files with 770 additions and 747 deletions.
4 changes: 4 additions & 0 deletions jest.config.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"testEnvironment": "jsdom",
"testPathIgnorePatterns": [
"/node_modules/",
"/platforms/",
Expand All @@ -9,6 +10,9 @@
"transform": {
"^.+\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(@react-native|react-native|react-native-vector-icons))"
],
"moduleNameMapper": {
"^react-native$": "react-native-web"
}
Expand Down
1 change: 1 addition & 0 deletions package.serve.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"expose-loader": "^4.1.0",
"file-loader": "^6.2.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"phonegap": "9.0.0+cordova.9.0.0",
"process": "^0.11.10",
"sass": "^1.62.1",
Expand Down
95 changes: 95 additions & 0 deletions www/__mocks__/cordovaMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import packageJsonBuild from '../../package.cordovabuild.json';

export const mockCordova = () => {
window['cordova'] ||= {};
window['cordova'].platformId ||= 'ios';
window['cordova'].platformVersion ||= packageJsonBuild.dependencies['cordova-ios'];
window['cordova'].plugins ||= {};
}

export const mockDevice = () => {
window['device'] ||= {};
window['device'].platform ||= 'ios';
window['device'].version ||= '14.0.0';
}

export const mockGetAppVersion = () => {
const mockGetAppVersion = {
getAppName: () => new Promise((rs, rj) => setTimeout(() => rs('Mock App'), 10)),
getPackageName: () => new Promise((rs, rj) => setTimeout(() => rs('com.example.mockapp'), 10)),
getVersionCode: () => new Promise((rs, rj) => setTimeout(() => rs('123'), 10)),
getVersionNumber: () => new Promise((rs, rj) => setTimeout(() => rs('1.2.3'), 10)),
}
window['cordova'] ||= {};
window['cordova'].getAppVersion = mockGetAppVersion;
}

export const mockBEMUserCache = () => {
const _cache = {};
const messages = [];
const mockBEMUserCache = {
getLocalStorage: (key: string, isSecure: boolean) => {
return new Promise((rs, rj) =>
setTimeout(() => {
rs(_cache[key]);
}, 100)
);
},
putLocalStorage: (key: string, value: any) => {
return new Promise<void>((rs, rj) =>
setTimeout(() => {
_cache[key] = value;
rs();
}, 100)
);
},
removeLocalStorage: (key: string) => {
return new Promise<void>((rs, rj) =>
setTimeout(() => {
delete _cache[key];
rs();
}, 100)
);
},
clearAll: () => {
return new Promise<void>((rs, rj) =>
setTimeout(() => {
for (let p in _cache) delete _cache[p];
rs();
}, 100)
);
},
listAllLocalStorageKeys: () => {
return new Promise<string[]>((rs, rj) =>
setTimeout(() => {
rs(Object.keys(_cache));
}, 100)
);
},
listAllUniqueKeys: () => {
return new Promise<string[]>((rs, rj) =>
setTimeout(() => {
rs(Object.keys(_cache));
}, 100)
);
},
putMessage: (key: string, value: any) => {
return new Promise<void>((rs, rj) =>
setTimeout(() => {
messages.push({ key, value });
rs();
}, 100)
);
},
getAllMessages: (key: string, withMetadata?: boolean) => {
return new Promise<any[]>((rs, rj) =>
setTimeout(() => {
rs(messages.filter(m => m.key == key).map(m => m.value));
}, 100)
);
}
}
window['cordova'] ||= {};
window['cordova'].plugins ||= {};
window['cordova'].plugins.BEMUserCache = mockBEMUserCache;
}
3 changes: 3 additions & 0 deletions www/__mocks__/globalMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const mockLogger = () => {
window['Logger'] = { log: console.log };
}
52 changes: 52 additions & 0 deletions www/__tests__/clientStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mockBEMUserCache, mockDevice, mockGetAppVersion } from "../__mocks__/cordovaMocks";
import { addStatError, addStatEvent, addStatReading, getAppVersion, statKeys } from "../js/plugin/clientStats";

mockDevice();
// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3"
mockGetAppVersion();
// clientStats.ts uses BEMUserCache to store the stats, so we need to mock that too
mockBEMUserCache();
const db = window['cordova']?.plugins?.BEMUserCache;

it('gets the app version', async () => {
const ver = await getAppVersion();
expect(ver).toEqual('1.2.3');
});

it('stores a client stats reading', async () => {
const reading = { a: 1, b: 2 };
await addStatReading(statKeys.REMINDER_PREFS, reading);
const storedMessages = await db.getAllMessages('stats/client_time', false);
expect(storedMessages).toContainEqual({
name: statKeys.REMINDER_PREFS,
ts: expect.any(Number),
reading,
client_app_version: '1.2.3',
client_os_version: '14.0.0'
});
});

it('stores a client stats event', async () => {
await addStatEvent(statKeys.BUTTON_FORCE_SYNC);
const storedMessages = await db.getAllMessages('stats/client_nav_event', false);
expect(storedMessages).toContainEqual({
name: statKeys.BUTTON_FORCE_SYNC,
ts: expect.any(Number),
reading: null,
client_app_version: '1.2.3',
client_os_version: '14.0.0'
});
});

it('stores a client stats error', async () => {
const errorStr = 'test error';
await addStatError(statKeys.MISSING_KEYS, errorStr);
const storedMessages = await db.getAllMessages('stats/client_error', false);
expect(storedMessages).toContainEqual({
name: statKeys.MISSING_KEYS,
ts: expect.any(Number),
reading: errorStr,
client_app_version: '1.2.3',
client_os_version: '14.0.0'
});
});
42 changes: 42 additions & 0 deletions www/__tests__/commHelper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mockLogger } from '../__mocks__/globalMocks';
import { fetchUrlCached } from '../js/commHelper';

mockLogger();

// mock for JavaScript 'fetch'
// we emulate a 100ms delay when i) fetching data and ii) parsing it as text
global.fetch = (url: string) => new Promise((rs, rj) => {
setTimeout(() => rs({
text: () => new Promise((rs, rj) => {
setTimeout(() => rs('mock data for ' + url), 100);
})
}));
}) as any;

it('fetches text from a URL and caches it so the next call is faster', async () => {
const tsBeforeCalls = Date.now();
const text1 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md');
const tsBetweenCalls = Date.now();
const text2 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md');
const tsAfterCalls = Date.now();
expect(text1).toEqual(expect.stringContaining('mock data'));
expect(text2).toEqual(expect.stringContaining('mock data'));
expect(tsAfterCalls - tsBetweenCalls).toBeLessThan(tsBetweenCalls - tsBeforeCalls);
});

/* The following functions from commHelper.ts are not tested because they are just wrappers
around the native functions in BEMServerComm.
If we wanted to test them, we would need to mock the native functions in BEMServerComm.
It would be better to do integration tests that actually call the native functions.
* - getRawEntries
* - getRawEntriesForLocalDate
* - getPipelineRangeTs
* - getPipelineCompleteTs
* - getMetrics
* - getAggregateData
* - registerUser
* - updateUser
* - getUser
* - putOne
*/
74 changes: 74 additions & 0 deletions www/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { mockBEMUserCache } from "../__mocks__/cordovaMocks";
import { mockLogger } from "../__mocks__/globalMocks";
import { storageClear, storageGet, storageRemove, storageSet } from "../js/plugin/storage";

// mocks used - storage.ts uses BEMUserCache and logging.
// localStorage is already mocked for us by Jest :)
mockLogger();
mockBEMUserCache();

it('stores a value and retrieves it back', async () => {
await storageSet('test1', 'test value 1');
const retVal = await storageGet('test1');
expect(retVal).toEqual('test value 1');
});

it('stores a value, removes it, and checks that it is gone', async () => {
await storageSet('test2', 'test value 2');
await storageRemove('test2');
const retVal = await storageGet('test2');
expect(retVal).toBeUndefined();
});

it('can store objects too', async () => {
const obj = { a: 1, b: 2 };
await storageSet('test6', obj);
const retVal = await storageGet('test6');
expect(retVal).toEqual(obj);
});

it('can also store complex nested objects with arrays', async () => {
const obj = { a: 1, b: { c: [1, 2, 3] } };
await storageSet('test7', obj);
const retVal = await storageGet('test7');
expect(retVal).toEqual(obj);
});

it('preserves values if local gets cleared', async () => {
await storageSet('test3', 'test value 3');
await storageClear({ local: true });
const retVal = await storageGet('test3');
expect(retVal).toEqual('test value 3');
});

it('preserves values if native gets cleared', async () => {
await storageSet('test4', 'test value 4');
await storageClear({ native: true });
const retVal = await storageGet('test4');
expect(retVal).toEqual('test value 4');
});

it('does not preserve values if both local and native are cleared', async () => {
await storageSet('test5', 'test value 5');
await storageClear({ local: true, native: true });
const retVal = await storageGet('test5');
expect(retVal).toBeUndefined();
});

it('preserves values if local gets cleared, then retrieved, then native gets cleared', async () => {
await storageSet('test8', 'test value 8');
await storageClear({ local: true });
await storageGet('test8');
await storageClear({ native: true });
const retVal = await storageGet('test8');
expect(retVal).toEqual('test value 8');
});

it('preserves values if native gets cleared, then retrieved, then local gets cleared', async () => {
await storageSet('test9', 'test value 9');
await storageClear({ native: true });
await storageGet('test9');
await storageClear({ local: true });
const retVal = await storageGet('test9');
expect(retVal).toEqual('test value 9');
});
2 changes: 0 additions & 2 deletions www/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import './css/main.diary.css';
import 'leaflet/dist/leaflet.css';

import './js/ngApp.js';
import './js/stats/clientstats.js';
import './js/splash/referral.js';
import './js/splash/customURL.js';
import './js/splash/startprefs.js';
Expand All @@ -31,4 +30,3 @@ import './js/control/uploadService.js';
import './js/metrics-factory.js';
import './js/metrics-mappings.js';
import './js/plugin/logger.ts';
import './js/plugin/storage.js';
55 changes: 0 additions & 55 deletions www/js/angular-react-helper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,6 @@
// Modified to use React 18 and wrap elements with the React Native Paper Provider

import angular from 'angular';
import { createRoot } from 'react-dom/client';
import React from 'react';
import { Provider as PaperProvider, MD3LightTheme as DefaultTheme, MD3Colors } from 'react-native-paper';
import { getTheme } from './appTheme';

function toBindings(propTypes) {
const bindings = {};
Object.keys(propTypes).forEach(key => bindings[key] = '<');
return bindings;
}

function toProps(propTypes, controller) {
const props = {};
Object.keys(propTypes).forEach(key => props[key] = controller[key]);
return props;
}

export function angularize(component, name, modulePath) {
component.module = modulePath;
const nameCamelCase = name[0].toLowerCase() + name.slice(1);
angular
.module(modulePath, [])
.component(nameCamelCase, makeComponentProps(component));
}

const theme = getTheme();
export function makeComponentProps(Component) {
const propTypes = Component.propTypes || {};
return {
bindings: toBindings(propTypes),
controller: ['$element', function($element) {
/* TODO: once the inf scroll list is converted to React and no longer uses
collection-repeat, we can just set the root here one time
and will not have to reassign it in $onChanges. */
/* Until then, React will complain everytime we reassign an element's root */
let root;
this.$onChanges = () => {
root = createRoot($element[0]);
const props = toProps(propTypes, this);
root.render(
<PaperProvider theme={theme}>
<style type="text/css">{`
@font-face {
font-family: 'MaterialCommunityIcons';
src: url(${require('react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf')}) format('truetype');
}`}
</style>
<Component { ...props } />
</PaperProvider>
);
};
this.$onDestroy = () => root.unmount();
}]
};
}

export function getAngularService(name: string) {
const injector = angular.element(document.body).injector();
Expand Down
Loading

0 comments on commit 5f530f5

Please sign in to comment.