Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking β€œSign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Unit Tests for AndroidHealthConnect Inventory Item #3

Merged
merged 10 commits into from
Dec 10, 2023
Prev Previous commit
Next Next commit
checkpoint testing for android health connect
kibagateaux committed Dec 10, 2023
commit dcbac86b233a27700901121688d4314061af7d44
3 changes: 3 additions & 0 deletions __mocks__/@segment/analytics-react-native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
createClient: jest.fn(),
};
16 changes: 16 additions & 0 deletions __mocks__/react-native-health-connect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
openHealthConnectSettings: jest.fn(),
// assume installed for testing purposes. not worth getting into weeds
initialize: () => true,
getSdkStatus: () => 3,
requestPermission: jest.fn(),
readRecords: jest.fn(),
revokeAllPermissions: jest.fn(),
getGrantedPermissions: jest.fn(),
SdkAvailabilityStatus: jest.fn(),
SdkAvailabilityStatus: {
SDK_UNAVAILABLE: 1,
SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED: 2,
SDK_AVAILABLE: 3,
},
};
11 changes: 11 additions & 0 deletions __mocks__/sentry-expo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
Native: {
captureException: jest.fn(),
captureMessage: jest.fn(),
},
Browser: {
captureException: jest.fn(),
captureMessage: jest.fn(),
},
init: jest.fn(),
};
2 changes: 2 additions & 0 deletions setupTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
jest.mock('sentry-expo');
jest.mock('@segment/analytics-react-native');
5 changes: 4 additions & 1 deletion src/app/inventory/[item].tsx
Original file line number Diff line number Diff line change
@@ -99,7 +99,10 @@ const ItemPage: React.FC<ItemPageProps> = () => {
? await item.equip(promptAsync)
: await item.equip();
// if result.error = "transceive fai" try majik ritual again
if (result) setStatus('post-equip');
if (result) {
setStatus('post-equip');
// TODO api request to add item to their avatar (:DataProvider or :Resource?)
}

// assume failure
setStatus('unequipped');
1 change: 1 addition & 0 deletions src/types/GameMechanics.ts
Original file line number Diff line number Diff line change
@@ -144,6 +144,7 @@ export interface ItemAbility {
// TODO I feel like this should all be rolled into InventoryItem
export interface InventoryIntegration {
item: InventoryItem;
permissions?: string[];
checkEligibility: () => Promise<boolean>;
getPermissions: () => Promise<boolean>;
initPermissions: () => Promise<boolean>;
123 changes: 123 additions & 0 deletions src/utils/inventory/__tests__/android-health-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import item from '../android-health-connect';
import mockHealth from 'react-native-health-connect';
import Permission from 'react-native-health-connect/types';

jest.mock('react-native-health-connect');

beforeEach(() => {
// assume app installed for simplicity of testing.
// manually override when needed
item.checkEligibility = async () => true;
});

describe('InventoryItem', () => {
it('should have correct properties', () => {
expect(item.item).toHaveProperty('id');
expect(item.item).toHaveProperty('name');
expect(item.item).toHaveProperty('image');
expect(item.item).toHaveProperty('tags');
expect(item.item).toHaveProperty('attributes');
expect(item.item).toHaveProperty('datasource');
expect(item.item).toHaveProperty('installLink');
expect(item.item).toHaveProperty('checkStatus');
expect(item.item).toHaveProperty('canEquip');
expect(item.item).toHaveProperty('equip');
expect(item.item).toHaveProperty('unequip');
});
});

// too many issues with platform mocking and stuff so just assume android and installed
describe('checkEligibility', () => {
// TODO figure out how to mock Platform.OS. Jest defaults to ios
// it('should return false if platform is not android', async () => {
// jest.mock('react-native', () => ({
// Platform: {
// OS: 'ios',
// },
// expect(await item.checkEligibility()).toBe(false);
// jest.mock('react-native', () => ({
// Platform: {
// OS: 'web',
// },
// expect(await item.checkEligibility()).toBe(false);
// });
// it('should return true if platform is android', async () => {
// initialize: () => true;
// getSdkStatus: () => 1,
// expect(await item.checkEligibility()).toBe(true);
// });
// it('should return false if SDK is not available', async () => {
// getSdkStatus: () => 1,
// expect(await item.checkEligibility()).toBe(false);
// });
// it('should throw error if SDK fails to initialize', async () => {
// getSdkStatus: () => 3,
// initialize: () => false,
// expect(await item.checkEligibility()).rejects.toThrow('Unable to initialize Android Health');
// });
});

describe('initPermissions', () => {
it('should return false if checkEligibility returns false', async () => {
item.checkEligibility = async () => false;
// mockHealth.requestPermission = async (perms) => perms;
expect(await item.initPermissions()).toBe(false);
});

it('should return true if permissions are granted', async () => {
item.checkEligibility = async () => true;
// mockHealth.requestPermission = async (perms) => perms;
mockHealth.getGrantedPermissions = async () => item.permissions as Permission[];
expect(await item.initPermissions()).toBe(true);
});

it('should return false if permissions are not granted', async () => {
mockHealth.requestPermission = async () => [];
mockHealth.getGrantedPermissions = async () => [];
expect(await item.initPermissions()).toBe(false);
});
});

describe('getPermissions', () => {
it('should return false if checkEligibility returns false', async () => {
item.checkEligibility = async () => false;
// mockHealth.requestPermission = async (perms) => perms;
mockHealth.getGrantedPermissions = async () => item.permissions as Permission[];
expect(await item.getPermissions()).toBe(false);
});

it('should return false if no permissions are granted', async () => {
// mockHealth.requestPermission = async (perms) => perms;
mockHealth.getGrantedPermissions = async () => [];
expect(await item.getPermissions()).toBe(false);
});

it('should return true if permissions are granted', async () => {
// mockHealth.requestPermission = async (perms) => perms;
mockHealth.getGrantedPermissions = async () => item.permissions as Permission[];
expect(await item.getPermissions()).toBe(true);
});
});

describe('equip', () => {
it('should return false if checkEligibility returns false', async () => {
item.checkEligibility = async () => false;
console.log('equip eligible', await item.checkEligibility());
expect(await item.equip()).toBe(false);
});

it('should return true if permissions are initialized', async () => {
item.initPermissions = async () => true;
expect(await item.equip()).toBe(true);
});

it('should return false if permissions are not initialized', async () => {
(item.initPermissions = async () => false), expect(await item.equip()).toBe(false);
});
});

describe('unequip', () => {
it('should return true', async () => {
expect(await item.unequip()).toBe(true);
});
});
111 changes: 0 additions & 111 deletions src/utils/inventory/android-health-connect.test.ts

This file was deleted.

56 changes: 32 additions & 24 deletions src/utils/inventory/android-health-connect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Platform } from 'react-native';
import { startOfDay, formatISO } from 'date-fns/fp';
import { keys, merge } from 'lodash';
import { sortBy, reduce } from 'lodash/fp';
@@ -13,7 +12,7 @@ import {
SdkAvailabilityStatus,
} from 'react-native-health-connect';
import { Permission } from 'react-native-health-connect/types';
import { getSentry, getSegment, TRACK_PERMS_REQUESTED, TRACK_DATA_QUERIED } from 'utils/logging';
import { debug, track, TRACK_PERMS_REQUESTED, TRACK_DATA_QUERIED } from 'utils/logging';

import {
InventoryIntegration,
@@ -32,7 +31,7 @@ import {
} from 'types/HealthData';

const ITEM_ID = 'AndroidHealthConnect';
const ANDROID_HEALTH_PERMISSIONS = [
const PERMISSIONS = [
// summaries
{ accessType: 'read', recordType: 'Steps' },
{ accessType: 'read', recordType: 'Distance' },
@@ -52,13 +51,18 @@ const ANDROID_HEALTH_PERMISSIONS = [
] as Permission[];

const checkEligibility = async (): Promise<boolean> => {
if (Platform.OS !== 'android') return false;
// cant test anything in file if we run this.
// if (Platform.OS !== 'android') return false;

const status = await getSdkStatus();
console.log('Inv:AndroidHealthConnect:checkEligibility: ', status);
console.log(
'Inv:AndroidHealthConnect:checkEligibility: ',
status,
SdkAvailabilityStatus.SDK_AVAILABLE,
);

if (status !== SdkAvailabilityStatus.SDK_AVAILABLE) {
console.log('Inv:AndroidHealthConnect:checkElig: NOT ELIIGBLE');
console.log('Inv:AndroidHealthConnect:checkElig: NOT ELIIGBLE - ');
// TODO link to Google play store link for download
return false;
}
@@ -77,25 +81,26 @@ const getPermissions = async () => {
console.log('Android Health is not available on this device');
return false;
}
} catch (e) {
} catch (e: unknown) {
console.log('Inv:AndroidHealthConnect:checkElig: ', e);
getSentry()?.captureException(e);
debug(e);
return false;
}

try {
const grantedPerms = await getGrantedPermissions();
console.log('Inv:AndroidHealthConnect:getPerm: GrantedPerms ', grantedPerms);

// if(grantedPerms !== ANDROID_HEALTH_PERMISSIONS) {
if (grantedPerms.length === 0) {
// allow them to deselect permissions if they want
// if(grantedPerms !== PERMISSIONS) {
if (!grantedPerms || grantedPerms.length === 0) {
console.log('Inv:AndroidHealthConnect:getPerm: NO PERMISSIONS');
return false;
}
return true;
} catch (e) {
} catch (e: unknown) {
console.log('Inv:AndroidHealthConnect:getPerm: ', e);
getSentry()?.captureException(e);
debug(e);
return false;
}
};
@@ -104,30 +109,32 @@ const initPermissions = async () => {
if (!(await checkEligibility())) return false;
try {
console.log('Inv:andoird-health-connect:Init');
const permissions = await requestPermission(ANDROID_HEALTH_PERMISSIONS);
getSegment()?.track(TRACK_PERMS_REQUESTED, { itemId: ITEM_ID });
const permissions = await requestPermission(PERMISSIONS);
track(TRACK_PERMS_REQUESTED, { itemId: ITEM_ID, permissions });
console.log('Inv:AndroidHealthConnect:Init: Permissions Granted!', permissions);
return true;
} catch (e) {
} catch (e: unknown) {
console.log('C:AndroidHealth:InitPerm: ERROR - ', e);
getSentry()?.captureException(e);
debug(e);
return false;
}
};

const equip: HoF = async () => {
// TODO refector checkEligibility out of all these funcs and into inventory UI flow
// Why? More functional and helps with testing
// return 0, 1, 2, on checkEligibility for 0. cant install, 1. installable, 2. installed
// call await initialize() manually on get/initPermissions and queryData
console.log('equip eligible', await checkEligibility());
if (!(await checkEligibility())) return false;

try {
await initPermissions();

// todo abstract to utils function
// getStepCount();

// TODO return array of string for permissions granted
return true;
} catch (e) {
} catch (e: unknown) {
console.log('Error requesting permissions: ', e);
getSentry()?.captureException(e);
debug(e);
return false;
}
};
@@ -170,14 +177,15 @@ const item: InventoryItem = {
],
checkStatus,
// must have app installed but not equipped yet
// TODO refactor to checkEligibility === 1
canEquip: async () => (await checkEligibility()) === true && (await getPermissions()) === false,
equip,
unequip,
// actions: [],
};

export default {
item,
permissions: PERMISSIONS,
checkEligibility,
getPermissions,
initPermissions,
@@ -206,7 +214,7 @@ export const queryHealthData = async ({
},
});

getSegment()?.track(TRACK_DATA_QUERIED, { itemId: ITEM_ID, actionType: activity });
track(TRACK_DATA_QUERIED, { itemId: ITEM_ID, actionType: activity });
console.log('Android Health Steps', records.slice(0, 10));

return records as AndroidHealthRecord[];
18 changes: 6 additions & 12 deletions src/utils/inventory/index.ts
Original file line number Diff line number Diff line change
@@ -38,19 +38,10 @@ export const isEquipping = checkItemHasStatus('equipping');
export const isUnequipped = checkItemHasStatus('unequipped');

export const getInventoryItems = async (username?: string): Promise<InventoryItem[]> => {
console.group('getInventoryItems user/platform : ', username, Platform.OS);
console.group(
'Platform OS : ',
Platform.select({ ios: 'ios', android: 'android', default: 'web' }),
);

// any data that can come directly from local device
const platformInventoryItems: InventoryItem[] = getPlatformItems(Platform.OS);

// console.log("platform inventory", platformInventoryItems)

// not logged in to get personalizations
if (!username) return platformInventoryItems;
// TODO: read from local storage
// no internet access to send request
if (!(await getNetworkState()).isNoosphere) return platformInventoryItems;

return axios
@@ -66,9 +57,12 @@ export const getInventoryItems = async (username?: string): Promise<InventoryIte
});
};

export const coreInventory = [maliksMajik.item, spotify.item, github.item];
// items that can be equipd via web browser even if app not installed
export const coreInventory = [spotify.item, github.item];
// any data that can come directly from local device
export const mobileInventory: InventoryItem[] = [
...coreInventory,
maliksMajik.item, // tricky bc technically works on web if on mobile phone
// locationForeground.item,
// locationBackground, // Dont need feature yet and it adds admin overhead for app review
];
25 changes: 23 additions & 2 deletions src/utils/logging.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Platform } from 'react-native';
import * as Sentry from 'sentry-expo';
import { SegmentClient as Segment, createClient } from '@segment/analytics-react-native';
import { JsonMap, SegmentClient as Segment, createClient } from '@segment/analytics-react-native';
import Constants from 'expo-constants';

import { getAppConfig, saveStorage } from 'utils/config';
@@ -20,7 +20,7 @@ export const getSentry = (): SentryClient => {

tracesSampleRate: 1.0,

// enableInExpoDevelopment: __DEV__, // dont issue sentry events if local development
enableInExpoDevelopment: !__DEV__, // dont issue sentry events if local development
debug: __DEV__, // If `true`, Sentry prints debugging information if error sending the event.

integrations: isNativeApp
@@ -45,6 +45,18 @@ export const getSentry = (): SentryClient => {
return sentryClient;
};

/**
* @dev does not send logs to sentry during local development.
* Should happen in client config already but just in case.
* @param err - Error exception thrown in runtime or manually crafted message
*/
export const debug = (err: string | Error | unknown) => {
if (!__DEV__)
err instanceof Error
? getSentry()?.captureException(err)
: getSentry()?.captureMessage(String(err));
};

export type SegmentClient = Segment | null;
let segmentClient: SegmentClient;
export const getSegment = () => {
@@ -57,6 +69,15 @@ export const getSegment = () => {
return segmentClient;
};

/**
* @dev does not track events during local development
* @param eventName - action user took within app to track in product analytics
* @param data - info about event to pass along
* @returns if event tracking was sent or not
*/
export const track = (eventName: string, data: JsonMap) =>
!__DEV__ && getSegment()?.track(eventName, data);

export const TRACK_PERMS_REQUESTED = 'TRACK_PERMISSIONS_REQUESTED';
export const TRACK_DATA_QUERIED = 'TRACK_DATA_QUERIED';

5 changes: 3 additions & 2 deletions src/utils/mayanese.ts
Original file line number Diff line number Diff line change
@@ -243,7 +243,7 @@ export const formatToUnix = format('XXXXX');
export const formatToCal = format('y-MM-dd');

// TODO run for past 40 years and next 20 years and export to JSON
export const tzolkinHistory: TzolkinConfig = (() => {
export const tzolkinHistory: TzolkinConfig = () => {
// const portalDay = PORTAL_DAY_0;
const portalDayUnix = PORTAL_DAY_0_UNIX;
const portalConfig = portalDay0Config;
@@ -320,4 +320,5 @@ export const tzolkinHistory: TzolkinConfig = (() => {
// const formattedMarkedDates = reduce((acc, d) => ({...acc, [d[0]]: d[1] }), {})(config);

return config;
})();
// })();
};
54 changes: 54 additions & 0 deletions sweep.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Sweep AI turns bugs & feature requests into code changes (https://sweep.dev)
# For details on our config file, check out our docs at https://docs.sweep.dev/usage/config

# This setting contains a list of rules that Sweep will check for. If any of these rules are broken in a new commit, Sweep will create an pull request to fix the broken rule.
rules:
- "All new business logic should have corresponding unit tests."
- "Refactor large functions to be more modular."
- "Add docstrings to all functions and file headers."
- "Use functional programming and never mutate data."

# This is the branch that Sweep will develop from and make pull requests to. Most people use 'main' or 'master' but some users also use 'dev' or 'staging'.
branch: 'main'

# By default Sweep will read the logs and outputs from your existing Github Actions. To disable this, set this to false.
gha_enabled: True

# This is the description of your project. It will be used by sweep when creating PRs. You can tell Sweep what's unique about your project, what frameworks you use, or anything else you want.
#
# Example:
#
# description: sweepai/sweep is a python project. The main api endpoints are in sweepai/api.py. Write code that adheres to PEP8.
description: >
Jinni is a React Native mobile app using the Expo framework written in typescript.
It is a super app wrapping other apps, services, and data sources using an simple inventory system to standardize integrations.
We aggregate data to create a tomogatchi that evolves based off your data to visualize your progress over time.
Jinni devs love your help and reward you with $10k for every successful PR.
# This sets whether to create pull requests as drafts. If this is set to True, then all pull requests will be created as drafts and GitHub Actions will not be triggered.
draft: False

# This is a list of directories that Sweep will not be able to edit.
blocked_dirs: ["patches/", ".husky/"]

# This is a list of documentation links that Sweep will use to help it understand your code. You can add links to documentation for any packages you use here.
#
# Example:
#
# docs:
# - PyGitHub: ["https://pygithub.readthedocs.io/en/latest/", "We use pygithub to interact with the GitHub API"]
# TODO have sweep generate docs on master-djinn API and add link to those here
docs:
- ReactNativeExpo: ["https://docs.expo.dev/", "Expo provides React Native packages, build tools, and deployment infrastructure"]
- JestTesting: ["https://jestjs.io/docs/", "React testing framework known for Arrange Act Assert pattern, easy mocks, and structuring with describe/it functions"]
- EthereumEthersJs: ["https://docs.ethers.org/v6/", "ethersjs is Ethereum wallet tooling. We use wallet address as player IDs and sign cryptographically sign API requests for authentication"]

# Sandbox executes commands in a sandboxed environment to validate code changes after every edit to guarantee pristine code. For more details, see the [Sandbox](./sandbox) page.
sandbox:
install:
- trunk init
- npm install
check:
- trunk fmt {file_path} || return 0
- npm test:ci
- trunk check --fix --print-failures {file_path}