Skip to content

Commit 9dcbdd3

Browse files
authored
Add thorough test cases for mobile app (#752)
* Add actor mock helper and tests * format tests * fix tests * Revert non-app tests * update tests * fix tests * coderabbit feedback * revert change * remove spurious tests
1 parent a651fdd commit 9dcbdd3

13 files changed

+735
-481
lines changed

app/src/utils/deeplinks.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ const decodeUrl = (encodedUrl: string): string => {
1616
try {
1717
return decodeURIComponent(encodedUrl);
1818
} catch (error) {
19-
console.error('Error decoding URL:', error);
19+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
20+
console.error('Error decoding URL:', error);
21+
}
2022
return encodedUrl;
2123
}
2224
};
2325

24-
const handleUrl = (uri: string) => {
26+
export const handleUrl = (uri: string) => {
2527
const decodedUri = decodeUrl(uri);
2628
const encodedData = queryString.parseUrl(decodedUri).query;
2729
const sessionId = encodedData.sessionId;
@@ -37,7 +39,9 @@ const handleUrl = (uri: string) => {
3739

3840
return;
3941
} catch (error) {
40-
console.error('Error parsing selfApp:', error);
42+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
43+
console.error('Error parsing selfApp:', error);
44+
}
4145
navigationRef.navigate('QRCodeTrouble');
4246
}
4347
} else if (sessionId && typeof sessionId === 'string') {
@@ -66,11 +70,15 @@ const handleUrl = (uri: string) => {
6670

6771
navigationRef.navigate('MockDataDeepLink');
6872
} catch (error) {
69-
console.error('Error parsing mock_passport data or navigating:', error);
73+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
74+
console.error('Error parsing mock_passport data or navigating:', error);
75+
}
7076
navigationRef.navigate('QRCodeTrouble');
7177
}
7278
} else {
73-
console.error('No sessionId or selfApp found in the data');
79+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
80+
console.error('No sessionId or selfApp found in the data');
81+
}
7482
navigationRef.navigate('QRCodeTrouble');
7583
}
7684
};

app/src/utils/notifications/notificationService.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import { PermissionsAndroid, Platform } from 'react-native';
66
const API_URL = 'https://notification.self.xyz';
77
const API_URL_STAGING = 'https://notification.staging.self.xyz';
88

9+
// Determine if running in test environment
10+
const isTestEnv = process.env.NODE_ENV === 'test';
11+
const log = (...args: any[]) => {
12+
if (!isTestEnv) console.log(...args);
13+
};
14+
const error = (...args: any[]) => {
15+
if (!isTestEnv) console.error(...args);
16+
};
17+
918
export const getStateMessage = (state: string): string => {
1019
switch (state) {
1120
case 'idle':
@@ -47,7 +56,7 @@ export async function requestNotificationPermission(): Promise<boolean> {
4756
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
4857
);
4958
if (permission !== PermissionsAndroid.RESULTS.GRANTED) {
50-
console.log('Notification permission denied');
59+
log('Notification permission denied');
5160
return false;
5261
}
5362
}
@@ -58,11 +67,11 @@ export async function requestNotificationPermission(): Promise<boolean> {
5867
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
5968
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
6069

61-
console.log('Notification permission status:', enabled);
70+
log('Notification permission status:', enabled);
6271

6372
return enabled;
64-
} catch (error) {
65-
console.error('Failed to request notification permission:', error);
73+
} catch (err) {
74+
error('Failed to request notification permission:', err);
6675
return false;
6776
}
6877
}
@@ -71,12 +80,12 @@ export async function getFCMToken(): Promise<string | null> {
7180
try {
7281
const token = await messaging().getToken();
7382
if (token) {
74-
console.log('FCM Token received');
83+
log('FCM Token received');
7584
return token;
7685
}
7786
return null;
78-
} catch (error) {
79-
console.error('Failed to get FCM token:', error);
87+
} catch (err) {
88+
error('Failed to get FCM token:', err);
8089
return null;
8190
}
8291
}
@@ -91,7 +100,7 @@ export async function registerDeviceToken(
91100
if (!token) {
92101
token = await messaging().getToken();
93102
if (!token) {
94-
console.log('No FCM token available');
103+
log('No FCM token available');
95104
return;
96105
}
97106
}
@@ -106,7 +115,7 @@ export async function registerDeviceToken(
106115
};
107116

108117
if (cleanedToken.length > 10) {
109-
console.log(
118+
log(
110119
'Registering device token:',
111120
`${cleanedToken.substring(0, 5)}...${cleanedToken.substring(
112121
cleanedToken.length - 5,
@@ -125,19 +134,12 @@ export async function registerDeviceToken(
125134

126135
if (!response.ok) {
127136
const errorText = await response.text();
128-
console.error(
129-
'Failed to register device token:',
130-
response.status,
131-
errorText,
132-
);
137+
error('Failed to register device token:', response.status, errorText);
133138
} else {
134-
console.log(
135-
'Device token registered successfully with session_id:',
136-
sessionId,
137-
);
139+
log('Device token registered successfully with session_id:', sessionId);
138140
}
139-
} catch (error) {
140-
console.error('Error registering device token:', error);
141+
} catch (err) {
142+
error('Error registering device token:', err);
141143
}
142144
}
143145

@@ -154,13 +156,13 @@ export interface RemoteMessage {
154156
export function setupNotifications(): () => void {
155157
messaging().setBackgroundMessageHandler(
156158
async (remoteMessage: RemoteMessage) => {
157-
console.log('Message handled in the background!', remoteMessage);
159+
log('Message handled in the background!', remoteMessage);
158160
},
159161
);
160162

161163
const unsubscribeForeground = messaging().onMessage(
162164
async (remoteMessage: RemoteMessage) => {
163-
console.log('Foreground message received:', remoteMessage);
165+
log('Foreground message received:', remoteMessage);
164166
},
165167
);
166168

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
2+
3+
import { useNavigation } from '@react-navigation/native';
4+
import { act, renderHook, waitFor } from '@testing-library/react-native';
5+
6+
jest.mock('@react-navigation/native', () => ({
7+
useNavigation: jest.fn(),
8+
}));
9+
10+
jest.mock('react-native-check-version', () => ({
11+
checkVersion: jest.fn(),
12+
}));
13+
14+
jest.mock('../../../src/utils/modalCallbackRegistry', () => ({
15+
registerModalCallbacks: jest.fn().mockReturnValue(1),
16+
}));
17+
18+
jest.mock('../../../src/utils/analytics', () => () => ({
19+
trackEvent: jest.fn(),
20+
}));
21+
22+
import { checkVersion } from 'react-native-check-version';
23+
24+
import { useAppUpdates } from '../../../src/hooks/useAppUpdates';
25+
import { registerModalCallbacks } from '../../../src/utils/modalCallbackRegistry';
26+
27+
const navigate = jest.fn();
28+
(useNavigation as jest.Mock).mockReturnValue({ navigate });
29+
30+
describe('useAppUpdates', () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
it('indicates update available', async () => {
36+
(checkVersion as jest.Mock).mockResolvedValue({
37+
needsUpdate: true,
38+
url: 'u',
39+
});
40+
41+
const { result } = renderHook(() => useAppUpdates());
42+
43+
// Wait for the async state update to complete
44+
await waitFor(() => {
45+
expect(result.current[0]).toBe(true);
46+
});
47+
});
48+
49+
it('shows modal when triggered', async () => {
50+
(checkVersion as jest.Mock).mockResolvedValue({
51+
needsUpdate: true,
52+
url: 'u',
53+
});
54+
55+
const { result } = renderHook(() => useAppUpdates());
56+
57+
// Wait for the async checkVersion to complete first
58+
await waitFor(() => {
59+
expect(result.current[0]).toBe(true);
60+
});
61+
62+
// Now test the modal trigger
63+
act(() => {
64+
result.current[1]();
65+
});
66+
67+
expect(registerModalCallbacks).toHaveBeenCalled();
68+
expect(navigate).toHaveBeenCalledWith('Modal', expect.any(Object));
69+
});
70+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
2+
3+
import { act, renderHook } from '@testing-library/react-native';
4+
5+
jest.useFakeTimers();
6+
7+
jest.mock('../../../src/navigation', () => ({
8+
navigationRef: { isReady: jest.fn(() => true), navigate: jest.fn() },
9+
}));
10+
11+
jest.mock('../../../src/hooks/useModal');
12+
jest.mock('@react-native-community/netinfo', () => ({
13+
useNetInfo: jest
14+
.fn()
15+
.mockReturnValue({ isConnected: false, isInternetReachable: false }),
16+
}));
17+
18+
import useConnectionModal from '../../../src/hooks/useConnectionModal';
19+
import { useModal } from '../../../src/hooks/useModal';
20+
21+
const showModal = jest.fn();
22+
const dismissModal = jest.fn();
23+
(useModal as jest.Mock).mockReturnValue({
24+
showModal,
25+
dismissModal,
26+
visible: false,
27+
});
28+
29+
describe('useConnectionModal', () => {
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it('shows modal when no connection', () => {
35+
const { result } = renderHook(() => useConnectionModal());
36+
act(() => {
37+
jest.advanceTimersByTime(2000);
38+
});
39+
expect(showModal).toHaveBeenCalled();
40+
expect(result.current.visible).toBe(false);
41+
});
42+
43+
it('dismisses modal when connection is restored', () => {
44+
(useModal as jest.Mock).mockReturnValue({
45+
showModal,
46+
dismissModal,
47+
visible: true,
48+
});
49+
50+
const { useNetInfo } = require('@react-native-community/netinfo');
51+
useNetInfo.mockReturnValue({
52+
isConnected: true,
53+
isInternetReachable: true,
54+
});
55+
56+
renderHook(() => useConnectionModal());
57+
act(() => {
58+
jest.advanceTimersByTime(2000);
59+
});
60+
expect(dismissModal).toHaveBeenCalled();
61+
});
62+
63+
it('does not show modal when hideNetworkModal is true', () => {
64+
jest.doMock('../../../src/stores/settingStore', () => ({
65+
useSettingStore: jest.fn(() => true),
66+
}));
67+
68+
renderHook(() => useConnectionModal());
69+
act(() => {
70+
jest.advanceTimersByTime(2000);
71+
});
72+
expect(showModal).not.toHaveBeenCalled();
73+
});
74+
75+
it('does not show modal when navigation is not ready', () => {
76+
const { navigationRef } = require('../../../src/navigation');
77+
navigationRef.isReady.mockReturnValue(false);
78+
79+
renderHook(() => useConnectionModal());
80+
act(() => {
81+
jest.advanceTimersByTime(2000);
82+
});
83+
expect(showModal).not.toHaveBeenCalled();
84+
});
85+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
2+
3+
import { useNavigation } from '@react-navigation/native';
4+
import { act, renderHook } from '@testing-library/react-native';
5+
6+
import {
7+
impactLight,
8+
impactMedium,
9+
selectionChange,
10+
} from '../../../src/utils/haptic';
11+
12+
jest.mock('@react-navigation/native', () => ({
13+
useNavigation: jest.fn(),
14+
}));
15+
16+
jest.mock('../../../src/utils/haptic', () => ({
17+
impactLight: jest.fn(),
18+
impactMedium: jest.fn(),
19+
selectionChange: jest.fn(),
20+
}));
21+
22+
const navigate = jest.fn();
23+
const popTo = jest.fn();
24+
(useNavigation as jest.Mock).mockReturnValue({ navigate, popTo });
25+
26+
import useHapticNavigation from '../../../src/hooks/useHapticNavigation';
27+
28+
describe('useHapticNavigation', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('navigates with light impact by default', () => {
34+
const { result } = renderHook(() => useHapticNavigation('Home'));
35+
act(() => {
36+
result.current();
37+
});
38+
expect(impactLight).toHaveBeenCalled();
39+
expect(navigate).toHaveBeenCalledWith('Home');
40+
});
41+
42+
it('uses cancel action', () => {
43+
const { result } = renderHook(() =>
44+
useHapticNavigation('Home', { action: 'cancel' }),
45+
);
46+
act(() => result.current());
47+
expect(selectionChange).toHaveBeenCalled();
48+
expect(popTo).toHaveBeenCalledWith('Home');
49+
});
50+
51+
it('uses confirm action', () => {
52+
const { result } = renderHook(() =>
53+
useHapticNavigation('Home', { action: 'confirm' }),
54+
);
55+
act(() => result.current());
56+
expect(impactMedium).toHaveBeenCalled();
57+
expect(navigate).toHaveBeenCalledWith('Home');
58+
});
59+
});

0 commit comments

Comments
 (0)