Skip to content

Commit 4b38667

Browse files
authored
Merge pull request #503 from apedley/services-tests
Tests for Health Services
2 parents 5e29cda + 7c468c5 commit 4b38667

20 files changed

+4518
-191
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { render, screen } from '@testing-library/react-native';
2+
3+
import MainScreen from '@/src/screens/MainScreen';
4+
import { NavigationContainer } from '@react-navigation/native';
5+
import SettingsScreen from '@/src/screens/SettingsScreen';
6+
import { createStackNavigator } from '@react-navigation/stack';
7+
8+
const Stack = createStackNavigator();
9+
10+
const AppNavigator = () => {
11+
return (
12+
<NavigationContainer>
13+
<Stack.Navigator>
14+
<Stack.Screen name="Home" component={MainScreen} />
15+
<Stack.Screen name="Settings" component={SettingsScreen} />
16+
</Stack.Navigator>
17+
</NavigationContainer>
18+
);
19+
};
20+
21+
describe('<MainScreen />', () => {
22+
test('renders Open Web Dashboard button', async () => {
23+
render(<AppNavigator />);
24+
expect(screen.getByText('Open Web Dashboard')).toBeVisible();
25+
26+
});
27+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
syncHealthData,
3+
initHealthConnect,
4+
} from '../../src/services/healthConnectService.ios';
5+
6+
import {
7+
isHealthDataAvailable,
8+
queryStatisticsForQuantity,
9+
queryQuantitySamples,
10+
} from '@kingstinct/react-native-healthkit';
11+
12+
jest.mock('../../src/services/LogService', () => ({
13+
addLog: jest.fn(),
14+
}));
15+
16+
jest.mock('../../src/services/api', () => ({
17+
syncHealthData: jest.fn(),
18+
}));
19+
20+
jest.mock('../../src/constants/HealthMetrics', () => ({
21+
HEALTH_METRICS: [
22+
{ recordType: 'Steps', stateKey: 'isStepsSyncEnabled', unit: 'count', type: 'step' },
23+
{ recordType: 'HeartRate', stateKey: 'isHeartRateSyncEnabled', unit: 'bpm', type: 'heart_rate' },
24+
{ recordType: 'ActiveCaloriesBurned', stateKey: 'isCaloriesSyncEnabled', unit: 'kcal', type: 'active_calories' },
25+
],
26+
}));
27+
28+
const api = require('../../src/services/api');
29+
30+
describe('syncHealthData (iOS)', () => {
31+
beforeEach(async () => {
32+
jest.clearAllMocks();
33+
// Initialize HealthKit as available for most tests
34+
isHealthDataAvailable.mockResolvedValue(true);
35+
await initHealthConnect();
36+
});
37+
38+
test('returns success with no data when no metrics enabled', async () => {
39+
const result = await syncHealthData('24h', {});
40+
41+
expect(result.success).toBe(true);
42+
expect(result.message).toBe('No new health data to sync.');
43+
expect(api.syncHealthData).not.toHaveBeenCalled();
44+
});
45+
46+
test('sends transformed data to API and returns response', async () => {
47+
// Mock Steps aggregation query
48+
queryStatisticsForQuantity.mockResolvedValue({
49+
sumQuantity: { quantity: 5000 },
50+
});
51+
api.syncHealthData.mockResolvedValue({ processed: 1, success: true });
52+
53+
const result = await syncHealthData('today', { isStepsSyncEnabled: true });
54+
55+
expect(result.success).toBe(true);
56+
expect(result.apiResponse).toEqual({ processed: 1, success: true });
57+
58+
// Verify the data shape sent to API - this catches transformation bugs
59+
expect(api.syncHealthData).toHaveBeenCalledWith(
60+
expect.arrayContaining([
61+
expect.objectContaining({
62+
type: 'step',
63+
value: 5000,
64+
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
65+
unit: 'count',
66+
}),
67+
])
68+
);
69+
});
70+
71+
test('returns error when API call fails', async () => {
72+
queryStatisticsForQuantity.mockResolvedValue({
73+
sumQuantity: { quantity: 5000 },
74+
});
75+
api.syncHealthData.mockRejectedValue(new Error('Network error'));
76+
77+
const result = await syncHealthData('today', { isStepsSyncEnabled: true });
78+
79+
expect(result.success).toBe(false);
80+
expect(result.error).toBe('Network error');
81+
});
82+
83+
test('continues processing when one metric returns no data', async () => {
84+
// Steps returns no data (this is the behavior when query fails - it returns empty)
85+
queryStatisticsForQuantity.mockResolvedValue(null);
86+
// HeartRate succeeds
87+
const today = new Date().toISOString();
88+
queryQuantitySamples.mockResolvedValue([
89+
{ startDate: today, quantity: 72 },
90+
]);
91+
api.syncHealthData.mockResolvedValue({ success: true });
92+
93+
const result = await syncHealthData('today', {
94+
isStepsSyncEnabled: true,
95+
isHeartRateSyncEnabled: true,
96+
});
97+
98+
expect(result.success).toBe(true);
99+
100+
// Verify HeartRate data is synced with correct shape, even though Steps had no data
101+
expect(api.syncHealthData).toHaveBeenCalledWith(
102+
expect.arrayContaining([
103+
expect.objectContaining({
104+
type: 'heart_rate',
105+
value: 72,
106+
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
107+
unit: 'bpm',
108+
}),
109+
])
110+
);
111+
});
112+
});
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* Tests for healthConnectService.js (Android)
3+
*
4+
* Note: We use jest.isolateModules to explicitly load the Android file
5+
* since Jest's platform resolution on macOS defaults to .ios.js files.
6+
*/
7+
8+
import { readRecords } from 'react-native-health-connect';
9+
10+
jest.mock('../../src/services/LogService', () => ({
11+
addLog: jest.fn(),
12+
}));
13+
14+
jest.mock('../../src/services/api', () => ({
15+
syncHealthData: jest.fn(),
16+
}));
17+
18+
jest.mock('../../src/constants/HealthMetrics', () => ({
19+
HEALTH_METRICS: [
20+
{ recordType: 'Steps', stateKey: 'isStepsSyncEnabled', unit: 'count', type: 'step' },
21+
{ recordType: 'HeartRate', stateKey: 'isHeartRateSyncEnabled', unit: 'bpm', type: 'heart_rate' },
22+
{ recordType: 'TotalCaloriesBurned', stateKey: 'isTotalCaloriesSyncEnabled', unit: 'kcal', type: 'total_calories' },
23+
],
24+
}));
25+
26+
const api = require('../../src/services/api');
27+
28+
// Load the Android-specific file using explicit .js extension
29+
// This bypasses Jest's platform resolution which would otherwise load .ios.js
30+
const androidService = require('../../src/services/healthConnectService.js');
31+
32+
describe('healthConnectService.js (Android)', () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
describe('getAggregatedTotalCaloriesByDate', () => {
38+
test('aggregates calories by date from multiple records', async () => {
39+
readRecords.mockResolvedValue({
40+
records: [
41+
{ startTime: '2024-01-15T08:00:00Z', energy: { inKilocalories: 200 } },
42+
{ startTime: '2024-01-15T12:00:00Z', energy: { inKilocalories: 300 } },
43+
],
44+
});
45+
46+
const result = await androidService.getAggregatedTotalCaloriesByDate(
47+
new Date('2024-01-15T00:00:00Z'),
48+
new Date('2024-01-15T23:59:59Z')
49+
);
50+
51+
expect(result).toHaveLength(1);
52+
expect(result[0]).toMatchObject({
53+
date: '2024-01-15',
54+
value: 500,
55+
type: 'total_calories',
56+
});
57+
});
58+
59+
test('returns empty array when no records', async () => {
60+
readRecords.mockResolvedValue({ records: [] });
61+
62+
const result = await androidService.getAggregatedTotalCaloriesByDate(
63+
new Date('2024-01-15T00:00:00Z'),
64+
new Date('2024-01-15T23:59:59Z')
65+
);
66+
67+
expect(result).toEqual([]);
68+
});
69+
70+
test('handles records with time field (fallback from startTime)', async () => {
71+
readRecords.mockResolvedValue({
72+
records: [
73+
{ time: '2024-01-16T10:00:00Z', energy: { inKilocalories: 150 } },
74+
],
75+
});
76+
77+
const result = await androidService.getAggregatedTotalCaloriesByDate(
78+
new Date('2024-01-16T00:00:00Z'),
79+
new Date('2024-01-16T23:59:59Z')
80+
);
81+
82+
expect(result[0].date).toBe('2024-01-16');
83+
});
84+
85+
test('handles missing energy property (treats as 0)', async () => {
86+
readRecords.mockResolvedValue({
87+
records: [
88+
{ startTime: '2024-01-15T08:00:00Z' },
89+
{ startTime: '2024-01-15T12:00:00Z', energy: { inKilocalories: 300 } },
90+
],
91+
});
92+
93+
const result = await androidService.getAggregatedTotalCaloriesByDate(
94+
new Date('2024-01-15T00:00:00Z'),
95+
new Date('2024-01-15T23:59:59Z')
96+
);
97+
98+
expect(result[0].value).toBe(300);
99+
});
100+
101+
test('rounds calorie values', async () => {
102+
readRecords.mockResolvedValue({
103+
records: [
104+
{ startTime: '2024-01-15T08:00:00Z', energy: { inKilocalories: 200.7 } },
105+
{ startTime: '2024-01-15T12:00:00Z', energy: { inKilocalories: 299.8 } },
106+
],
107+
});
108+
109+
const result = await androidService.getAggregatedTotalCaloriesByDate(
110+
new Date('2024-01-15T00:00:00Z'),
111+
new Date('2024-01-15T23:59:59Z')
112+
);
113+
114+
expect(result[0].value).toBe(501);
115+
});
116+
117+
test('groups multiple days correctly', async () => {
118+
readRecords.mockResolvedValue({
119+
records: [
120+
{ startTime: '2024-01-15T10:00:00Z', energy: { inKilocalories: 200 } },
121+
{ startTime: '2024-01-16T10:00:00Z', energy: { inKilocalories: 300 } },
122+
{ startTime: '2024-01-16T14:00:00Z', energy: { inKilocalories: 100 } },
123+
],
124+
});
125+
126+
const result = await androidService.getAggregatedTotalCaloriesByDate(
127+
new Date('2024-01-15T00:00:00Z'),
128+
new Date('2024-01-16T23:59:59Z')
129+
);
130+
131+
expect(result).toHaveLength(2);
132+
expect(result.find(r => r.date === '2024-01-15').value).toBe(200);
133+
expect(result.find(r => r.date === '2024-01-16').value).toBe(400);
134+
});
135+
});
136+
137+
describe('getAggregatedDistanceByDate', () => {
138+
test('aggregates distance by date', async () => {
139+
readRecords.mockResolvedValue({
140+
records: [
141+
{ startTime: '2024-01-15T08:00:00Z', distance: { inMeters: 1000 } },
142+
{ startTime: '2024-01-15T12:00:00Z', distance: { inMeters: 2000 } },
143+
],
144+
});
145+
146+
const result = await androidService.getAggregatedDistanceByDate(
147+
new Date('2024-01-15T00:00:00Z'),
148+
new Date('2024-01-15T23:59:59Z')
149+
);
150+
151+
expect(result).toHaveLength(1);
152+
expect(result[0]).toMatchObject({
153+
date: '2024-01-15',
154+
value: 3000,
155+
type: 'distance',
156+
});
157+
});
158+
159+
test('returns empty array when no records', async () => {
160+
readRecords.mockResolvedValue({ records: [] });
161+
162+
const result = await androidService.getAggregatedDistanceByDate(
163+
new Date('2024-01-15T00:00:00Z'),
164+
new Date('2024-01-15T23:59:59Z')
165+
);
166+
167+
expect(result).toEqual([]);
168+
});
169+
170+
test('handles missing distance property', async () => {
171+
readRecords.mockResolvedValue({
172+
records: [
173+
{ startTime: '2024-01-15T08:00:00Z' },
174+
{ startTime: '2024-01-15T12:00:00Z', distance: { inMeters: 2000 } },
175+
],
176+
});
177+
178+
const result = await androidService.getAggregatedDistanceByDate(
179+
new Date('2024-01-15T00:00:00Z'),
180+
new Date('2024-01-15T23:59:59Z')
181+
);
182+
183+
expect(result[0].value).toBe(2000);
184+
});
185+
});
186+
187+
describe('getAggregatedFloorsClimbedByDate', () => {
188+
test('aggregates floors by date', async () => {
189+
readRecords.mockResolvedValue({
190+
records: [
191+
{ startTime: '2024-01-15T08:00:00Z', floors: 5 },
192+
{ startTime: '2024-01-15T12:00:00Z', floors: 3 },
193+
],
194+
});
195+
196+
const result = await androidService.getAggregatedFloorsClimbedByDate(
197+
new Date('2024-01-15T00:00:00Z'),
198+
new Date('2024-01-15T23:59:59Z')
199+
);
200+
201+
expect(result).toHaveLength(1);
202+
expect(result[0]).toMatchObject({
203+
date: '2024-01-15',
204+
value: 8,
205+
type: 'floors_climbed',
206+
});
207+
});
208+
209+
test('returns empty array when no records', async () => {
210+
readRecords.mockResolvedValue({ records: [] });
211+
212+
const result = await androidService.getAggregatedFloorsClimbedByDate(
213+
new Date('2024-01-15T00:00:00Z'),
214+
new Date('2024-01-15T23:59:59Z')
215+
);
216+
217+
expect(result).toEqual([]);
218+
});
219+
220+
test('handles missing floors property', async () => {
221+
readRecords.mockResolvedValue({
222+
records: [
223+
{ startTime: '2024-01-15T08:00:00Z' },
224+
{ startTime: '2024-01-15T12:00:00Z', floors: 3 },
225+
],
226+
});
227+
228+
const result = await androidService.getAggregatedFloorsClimbedByDate(
229+
new Date('2024-01-15T00:00:00Z'),
230+
new Date('2024-01-15T23:59:59Z')
231+
);
232+
233+
expect(result[0].value).toBe(3);
234+
});
235+
});
236+
237+
});

0 commit comments

Comments
 (0)