Skip to content

Commit b85d1a5

Browse files
committed
refactor(voip): inject isMasterDetail for call-room navigation and add tests.
navigateToCallRoom no longer reads Redux; CallButtons and MediaCallHeader pass layout from useAppSelector. registerPushToken and disconnect VoIP lifecycle are covered by unit tests. connect.ios tests now mock deviceInfo so isIOS matches connect.ts. Made-with: Cursor
1 parent 38ca1df commit b85d1a5

7 files changed

Lines changed: 177 additions & 33 deletions

File tree

app/containers/MediaCallHeader/components/Content.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Pressable, StyleSheet, View } from 'react-native';
22

3+
import { useAppSelector } from '../../../lib/hooks/useAppSelector';
34
import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom';
45
import { useCallStore } from '../../../lib/services/voip/useCallStore';
56
import Title from './Title';
@@ -19,6 +20,7 @@ const styles = StyleSheet.create({
1920
});
2021

2122
export const Content = () => {
23+
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
2224
const roomId = useCallStore(state => state.roomId);
2325
const contact = useCallStore(state => state.contact);
2426
const contentDisabled = Boolean(contact.sipExtension) || roomId == null;
@@ -29,7 +31,7 @@ export const Content = () => {
2931
testID='media-call-header-content'
3032
disabled={contentDisabled}
3133
onPress={() => {
32-
navigateToCallRoom().catch(() => undefined);
34+
navigateToCallRoom({ isMasterDetail }).catch(() => undefined);
3335
}}
3436
style={pressableStyle}>
3537
<View style={styles.container}>

app/lib/services/connect.ios.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { determineAuthType } from './connect';
22

33
jest.mock('./voip/MediaSessionInstance', () => ({
4-
mediaSessionInstance: { reset: jest.fn(), init: jest.fn() }
4+
mediaSessionInstance: { reset: jest.fn() }
55
}));
66

7-
// Mock the isIOS helper to return true for iOS-specific tests
8-
jest.mock('../methods/helpers', () => ({
9-
...jest.requireActual('../methods/helpers'),
7+
jest.mock('../methods/helpers/deviceInfo', () => ({
8+
...jest.requireActual('../methods/helpers/deviceInfo'),
109
isIOS: true
1110
}));
1211

app/lib/services/connect.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { determineAuthType } from './connect';
1+
import { determineAuthType, disconnect } from './connect';
2+
import { mediaSessionInstance } from './voip/MediaSessionInstance';
23

34
jest.mock('./voip/MediaSessionInstance', () => ({
4-
mediaSessionInstance: { reset: jest.fn(), init: jest.fn() }
5+
mediaSessionInstance: { reset: jest.fn() }
56
}));
67

78
// Mock the isIOS helper
@@ -305,4 +306,11 @@ describe('determineAuthType', () => {
305306
});
306307
});
307308

309+
describe('VoIP media session lifecycle (disconnect)', () => {
310+
it('calls mediaSessionInstance.reset when disconnect runs', () => {
311+
disconnect();
312+
expect(mediaSessionInstance.reset).toHaveBeenCalledTimes(1);
313+
});
314+
});
315+
308316
// Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts

app/lib/services/restApi.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,61 @@
11
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';
2+
import { Platform } from 'react-native';
23

34
import { mediaCallsStateSignals } from './restApi';
45

56
const mockSdkGet = jest.fn();
7+
const mockSdkPost = jest.fn();
8+
69
jest.mock('./sdk', () => ({
710
__esModule: true,
811
default: {
9-
get: (...args: unknown[]) => mockSdkGet(...args)
12+
get: (...args: unknown[]) => mockSdkGet(...args),
13+
post: (...args: unknown[]) => mockSdkPost(...args)
14+
}
15+
}));
16+
17+
jest.mock('../notifications', () => ({
18+
getDeviceToken: jest.fn()
19+
}));
20+
21+
jest.mock('../native/NativeVoip', () => ({
22+
__esModule: true,
23+
default: {
24+
getLastVoipToken: jest.fn()
1025
}
1126
}));
1227

28+
jest.mock('react-native-device-info', () => {
29+
const mock = require('react-native-device-info/jest/react-native-device-info-mock');
30+
const getUniqueId = jest.fn(() => Promise.resolve('unique-device-id'));
31+
const defaultExport = {
32+
...mock,
33+
getUniqueId
34+
};
35+
return {
36+
__esModule: true,
37+
default: defaultExport,
38+
getUniqueId
39+
};
40+
});
41+
42+
function loadRegisterPushToken(platform: 'ios' | 'android' = 'android') {
43+
jest.resetModules();
44+
Object.defineProperty(Platform, 'OS', { configurable: true, writable: true, value: platform });
45+
// eslint-disable-next-line @typescript-eslint/no-require-imports
46+
const notifications = require('../notifications');
47+
// eslint-disable-next-line @typescript-eslint/no-require-imports
48+
const voipNative = require('../native/NativeVoip').default;
49+
// eslint-disable-next-line @typescript-eslint/no-require-imports
50+
const { registerPushToken } = require('./restApi');
51+
return {
52+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
53+
registerPushToken: registerPushToken as typeof import('./restApi').registerPushToken,
54+
getDeviceToken: jest.mocked(notifications.getDeviceToken),
55+
getLastVoipToken: jest.mocked(voipNative.getLastVoipToken)
56+
};
57+
}
58+
1359
describe('mediaCallsStateSignals', () => {
1460
beforeEach(() => {
1561
jest.clearAllMocks();
@@ -55,3 +101,86 @@ describe('mediaCallsStateSignals', () => {
55101
expect(result.success).toBe(false);
56102
});
57103
});
104+
105+
describe('registerPushToken', () => {
106+
const platformOsAtSuiteStart = Platform.OS;
107+
108+
afterEach(() => {
109+
Object.defineProperty(Platform, 'OS', { configurable: true, writable: true, value: platformOsAtSuiteStart });
110+
});
111+
112+
beforeEach(() => {
113+
jest.clearAllMocks();
114+
mockSdkPost.mockResolvedValue(undefined);
115+
});
116+
117+
it('returns early when there is no device push token', async () => {
118+
const { registerPushToken, getDeviceToken: getToken } = loadRegisterPushToken();
119+
getToken.mockReturnValue('');
120+
121+
await registerPushToken();
122+
123+
expect(mockSdkPost).not.toHaveBeenCalled();
124+
});
125+
126+
it('on iOS returns early when push token exists but VoIP token is missing', async () => {
127+
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
128+
getToken.mockReturnValue('apns-token');
129+
getVoip.mockReturnValue('');
130+
131+
await registerPushToken();
132+
133+
expect(mockSdkPost).not.toHaveBeenCalled();
134+
});
135+
136+
it('on Android still registers when VoIP token is missing', async () => {
137+
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('android');
138+
getToken.mockReturnValue('fcm-token');
139+
getVoip.mockReturnValue('');
140+
141+
await registerPushToken();
142+
143+
expect(mockSdkPost).toHaveBeenCalledTimes(1);
144+
expect(mockSdkPost).toHaveBeenCalledWith(
145+
'push.token',
146+
expect.objectContaining({
147+
id: 'unique-device-id',
148+
value: 'fcm-token',
149+
type: 'gcm',
150+
appName: expect.any(String)
151+
})
152+
);
153+
const payload = mockSdkPost.mock.calls[0][1] as Record<string, unknown>;
154+
expect(Object.prototype.hasOwnProperty.call(payload, 'voipToken')).toBe(false);
155+
});
156+
157+
it('dedupes when the same push and VoIP tokens are registered again', async () => {
158+
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
159+
getToken.mockReturnValue('apns-token');
160+
getVoip.mockReturnValue('voip-token');
161+
162+
await registerPushToken();
163+
await registerPushToken();
164+
165+
expect(mockSdkPost).toHaveBeenCalledTimes(1);
166+
});
167+
168+
it('on iOS posts apn payload with voipToken when both tokens are present', async () => {
169+
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
170+
getToken.mockReturnValue('apns-token');
171+
getVoip.mockReturnValue('voip-token');
172+
173+
await registerPushToken();
174+
175+
expect(mockSdkPost).toHaveBeenCalledWith(
176+
'push.token',
177+
expect.objectContaining({
178+
id: 'unique-device-id',
179+
value: 'apns-token',
180+
type: 'apn',
181+
appName: expect.any(String),
182+
voipToken: 'voip-token'
183+
})
184+
);
185+
});
186+
});

app/lib/services/voip/navigateToCallRoom.test.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { goRoom } from '../../methods/helpers/goRoom';
22
import Navigation from '../../navigation/appNavigation';
3-
import { store } from '../../store/auxStore';
43
import { useCallStore } from './useCallStore';
54
import { navigateToCallRoom } from './navigateToCallRoom';
65
import { SubscriptionType } from '../../../definitions';
@@ -15,12 +14,6 @@ jest.mock('../../methods/helpers/goRoom', () => ({
1514
goRoom: jest.fn().mockResolvedValue(undefined)
1615
}));
1716

18-
jest.mock('../../store/auxStore', () => ({
19-
store: {
20-
getState: jest.fn()
21-
}
22-
}));
23-
2417
jest.mock('../../navigation/appNavigation', () => ({
2518
__esModule: true,
2619
default: {
@@ -31,7 +24,6 @@ jest.mock('../../navigation/appNavigation', () => ({
3124

3225
const mockGetState = jest.mocked(useCallStore.getState);
3326
const mockGoRoom = jest.mocked(goRoom);
34-
const mockStoreGetState = jest.mocked(store.getState);
3527
const mockNavigation = jest.mocked(Navigation);
3628

3729
type CallStoreSnapshot = ReturnType<typeof useCallStore.getState>;
@@ -48,7 +40,6 @@ describe('navigateToCallRoom', () => {
4840

4941
beforeEach(() => {
5042
jest.clearAllMocks();
51-
mockStoreGetState.mockReturnValue({ app: { isMasterDetail: true } } as ReturnType<typeof store.getState>);
5243
mockNavigation.getCurrentRoute.mockReturnValue({ name: 'RoomsListView' } as any);
5344
});
5445

@@ -62,7 +53,7 @@ describe('navigateToCallRoom', () => {
6253
})
6354
);
6455

65-
await navigateToCallRoom();
56+
await navigateToCallRoom({ isMasterDetail: true });
6657

6758
expect(mockGoRoom).not.toHaveBeenCalled();
6859
expect(toggleFocus).not.toHaveBeenCalled();
@@ -79,7 +70,7 @@ describe('navigateToCallRoom', () => {
7970
})
8071
);
8172

82-
await navigateToCallRoom();
73+
await navigateToCallRoom({ isMasterDetail: true });
8374

8475
expect(mockGoRoom).not.toHaveBeenCalled();
8576
expect(toggleFocus).not.toHaveBeenCalled();
@@ -96,7 +87,7 @@ describe('navigateToCallRoom', () => {
9687
})
9788
);
9889

99-
await navigateToCallRoom();
90+
await navigateToCallRoom({ isMasterDetail: true });
10091

10192
expect(mockGoRoom).not.toHaveBeenCalled();
10293
expect(toggleFocus).not.toHaveBeenCalled();
@@ -113,7 +104,7 @@ describe('navigateToCallRoom', () => {
113104
})
114105
);
115106

116-
await navigateToCallRoom();
107+
await navigateToCallRoom({ isMasterDetail: true });
117108

118109
expect(toggleFocus).toHaveBeenCalledTimes(1);
119110
expect(mockGoRoom).toHaveBeenCalledWith({
@@ -133,7 +124,7 @@ describe('navigateToCallRoom', () => {
133124
})
134125
);
135126

136-
await navigateToCallRoom();
127+
await navigateToCallRoom({ isMasterDetail: true });
137128

138129
expect(toggleFocus).not.toHaveBeenCalled();
139130
expect(mockGoRoom).toHaveBeenCalledWith({
@@ -153,7 +144,7 @@ describe('navigateToCallRoom', () => {
153144
})
154145
);
155146

156-
await navigateToCallRoom();
147+
await navigateToCallRoom({ isMasterDetail: true });
157148

158149
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
159150
expect(mockGoRoom).toHaveBeenCalledWith({
@@ -174,7 +165,7 @@ describe('navigateToCallRoom', () => {
174165
})
175166
);
176167

177-
await navigateToCallRoom();
168+
await navigateToCallRoom({ isMasterDetail: true });
178169

179170
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
180171
expect(mockGoRoom).toHaveBeenCalled();
@@ -192,7 +183,7 @@ describe('navigateToCallRoom', () => {
192183
})
193184
);
194185

195-
await navigateToCallRoom();
186+
await navigateToCallRoom({ isMasterDetail: true });
196187

197188
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
198189
expect(mockGoRoom).toHaveBeenCalled();
@@ -210,9 +201,27 @@ describe('navigateToCallRoom', () => {
210201
})
211202
);
212203

213-
await navigateToCallRoom();
204+
await navigateToCallRoom({ isMasterDetail: true });
214205

215206
expect(mockNavigation.navigate).not.toHaveBeenCalled();
216207
expect(mockGoRoom).toHaveBeenCalled();
217208
});
209+
210+
it('passes isMasterDetail from the caller into goRoom', async () => {
211+
mockGetState.mockReturnValue(
212+
mockCallStoreState({
213+
roomId: 'rid-1',
214+
contact: { username: 'alice', sipExtension: '' },
215+
focused: false,
216+
toggleFocus
217+
})
218+
);
219+
220+
await navigateToCallRoom({ isMasterDetail: false });
221+
222+
expect(mockGoRoom).toHaveBeenCalledWith({
223+
item: { rid: 'rid-1', name: 'alice', t: SubscriptionType.DIRECT },
224+
isMasterDetail: false
225+
});
226+
});
218227
});

app/lib/services/voip/navigateToCallRoom.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { SubscriptionType } from '../../../definitions';
22
import { goRoom } from '../../methods/helpers/goRoom';
33
import Navigation from '../../navigation/appNavigation';
4-
import { store } from '../../store/auxStore';
54
import { useCallStore } from './useCallStore';
65

76
/**
87
* From the VoIP UI, open the DM for the active call: minimizes CallView when it is focused, then navigates.
98
* No-ops for SIP calls or when room id or username is missing.
109
*/
11-
export async function navigateToCallRoom(): Promise<void> {
10+
export async function navigateToCallRoom({ isMasterDetail }: { isMasterDetail: boolean }): Promise<void> {
1211
const { roomId, contact, focused, toggleFocus } = useCallStore.getState();
1312

1413
if (!roomId || contact.sipExtension) {
@@ -24,10 +23,6 @@ export async function navigateToCallRoom(): Promise<void> {
2423
toggleFocus();
2524
}
2625

27-
const {
28-
app: { isMasterDetail }
29-
} = store.getState();
30-
3126
// If we're not in the chats navigator (e.g., in Profile/Settings/Accessibility screens),
3227
// navigate to ChatsStackNavigator first to ensure goRoom works correctly
3328
const currentRoute = Navigation.getCurrentRoute() as any;

0 commit comments

Comments
 (0)