[MM-59773] Add unit tests for Calls (#8480)

* Unit tests for app/products/calls/observers

* Unit tests for app/products/calls/actions

* Unit tests for app/products/calls/client

* Unit tests for app/products/calls/connection

* Unit tests for app/products/calls/connection/websocket_event_handlers.ts

* Unit tests for app/products/calls/connection/connection.ts

* Unit tests for app/products/calls/state/actions.ts

* Unit tests for app/products/calls/utils.ts

* Unit tests for app/products/calls/alerts.ts

* Unit tests for app/products/calls/calls_manager.ts

* Unit tests for app/products/calls/hooks.ts

* Factor out force logout error

* Reduce use of 'as jest.Mock'

* Add test case

* Use constants

* Better naming

* Fix types after merge

* Fix test
This commit is contained in:
Claudio Costa 2025-01-21 12:02:03 -06:00 committed by GitHub
parent 681585d5db
commit 44bce6c319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 4095 additions and 45 deletions

View file

@ -5,6 +5,7 @@ import assert from 'assert';
import {act, renderHook} from '@testing-library/react-hooks'; // Use instead of react-native version due to different behavior. Consider migrating
import {createIntl} from 'react-intl';
import {Alert} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import * as CallsActions from '@calls/actions';
@ -25,12 +26,14 @@ import {
useChannelsWithCalls,
useCurrentCall,
userJoinedCall,
getCurrentCall,
} from '@calls/state';
import {
type Call,
type CallsState,
type ChannelsWithCalls,
type CurrentCall,
AudioDevice,
DefaultCallsConfig,
DefaultCallsState,
} from '@calls/types/calls';
@ -38,6 +41,8 @@ import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import type {CallJobState} from '@mattermost/calls/lib/types';
const mockClient = {
getCalls: jest.fn(() => [
{
@ -54,12 +59,15 @@ const mockClient = {
enabled: true,
},
]),
getCallsConfig: jest.fn(() => ({
ICEServers: ['mattermost.com'],
AllowEnableCalls: true,
DefaultEnabled: true,
last_retrieved_at: 1234,
})),
hostMake: jest.fn(),
hostMute: jest.fn(),
hostMuteOthers: jest.fn(),
hostScreenOff: jest.fn(),
hostLowerHand: jest.fn(),
hostRemove: jest.fn(),
endCall: jest.fn(),
getCallsConfig: jest.fn(),
getCallForChannel: jest.fn(),
getVersion: jest.fn(() => ({})),
getPluginsManifests: jest.fn(() => (
[
@ -79,6 +87,8 @@ jest.mock('@calls/connection/connection', () => ({
mute: jest.fn(),
unmute: jest.fn(),
waitForPeerConnection: jest.fn(() => Promise.resolve()),
initializeVoiceTrack: jest.fn(),
sendReaction: jest.fn(),
})),
}));
@ -98,6 +108,7 @@ jest.mock('@calls/alerts', () => {
needsRecordingErrorAlert: jest.fn(),
needsRecordingWillBePostedAlert: jest.fn(),
showErrorAlertOnClose: alerts.showErrorAlertOnClose,
leaveAndJoinWithAlert: alerts.leaveAndJoinWithAlert,
};
});
@ -111,6 +122,28 @@ jest.mock('react-native-navigation', () => ({
},
}));
jest.mock('@actions/remote/session', () => ({
forceLogoutIfNecessary: jest.fn(),
}));
jest.mock('@queries/servers/user', () => ({
getCurrentUser: jest.fn(),
getUserById: jest.fn(),
}));
jest.mock('@queries/servers/channel', () => ({
getChannelById: jest.fn(),
}));
jest.mock('@queries/servers/system', () => ({
getLicense: jest.fn(),
getConfig: jest.fn(),
}));
jest.mock('@queries/servers/preference', () => ({
queryDisplayNamePreferences: jest.fn(),
}));
const addFakeCall = (serverUrl: string, channelId: string) => {
const call: Call = {
id: 'call',
@ -172,6 +205,8 @@ describe('Actions.Calls', () => {
InCallManager.setSpeakerphoneOn = jest.fn();
InCallManager.setForceSpeakerphoneOn = jest.fn();
InCallManager.chooseAudioRoute = jest.fn();
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
@ -195,8 +230,13 @@ describe('Actions.Calls', () => {
newConnection.mockClear();
updateThreadFollowing.mockClear();
mockClient.getCalls.mockClear();
mockClient.getCallsConfig.mockClear();
mockClient.getPluginsManifests.mockClear();
mockClient.getPluginsManifests = jest.fn(() => (
[
{id: 'playbooks'},
{id: 'com.mattermost.calls'},
]
));
mockClient.enableChannelCalls.mockClear();
// reset to default state for each test
@ -208,6 +248,9 @@ describe('Actions.Calls', () => {
});
});
const forceLogout = require('@actions/remote/session').forceLogoutIfNecessary;
const forceLogoutError = {status_code: 401};
it('joinCall', async () => {
// setup
const {result} = renderHook(() => {
@ -235,6 +278,32 @@ describe('Actions.Calls', () => {
await act(async () => {
CallsActions.leaveCall();
});
// Test error case
newConnection.mockRejectedValueOnce(forceLogoutError);
await act(async () => {
await expect(CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}))).resolves.toStrictEqual({error: forceLogoutError});
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
// Test failure to connect case
const connection = {
disconnect: jest.fn(),
waitForPeerConnection: jest.fn().mockRejectedValueOnce(new Error('failed to connect')),
};
newConnection.mockResolvedValueOnce(connection);
await act(async () => {
const res = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
expect(res).toStrictEqual({error: 'unable to connect to the voice call: Error: failed to connect'});
expect(connection.disconnect).toHaveBeenCalled();
});
});
it('leaveCall', async () => {
@ -346,14 +415,25 @@ describe('Actions.Calls', () => {
return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
});
// Test successful case
await act(async () => {
await CallsActions.loadCalls('server1', 'userId1');
const successResult = await CallsActions.loadCalls('server1', 'userId1');
expect(successResult.data).toBeDefined();
expect(mockClient.getCalls).toHaveBeenCalled();
assert.equal((result.current[0] as CallsState).calls['channel-1'].channelId, 'channel-1');
assert.equal((result.current[0] as CallsState).enabled['channel-1'], true);
assert.equal((result.current[1] as ChannelsWithCalls)['channel-1'], true);
assert.equal((result.current[2] as CurrentCall | null), null);
});
// Test error case
mockClient.getCalls = jest.fn().mockRejectedValueOnce(forceLogoutError);
await act(async () => {
const errorResult = await CallsActions.loadCalls('server1', 'userId1');
expect(errorResult.error).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
expect(mockClient.getCalls).toHaveBeenCalled();
assert.equal((result.current[0] as CallsState).calls['channel-1'].channelId, 'channel-1');
assert.equal((result.current[0] as CallsState).enabled['channel-1'], true);
assert.equal((result.current[1] as ChannelsWithCalls)['channel-1'], true);
assert.equal((result.current[2] as CurrentCall | null), null);
});
it('loadCalls fails from server', async () => {
@ -389,23 +469,53 @@ describe('Actions.Calls', () => {
// setup
const {result} = renderHook(() => useCallsConfig('server1'));
// Test successful case
await act(async () => {
await CallsActions.loadConfig('server1', false, 'Server Switch');
mockClient.getCallsConfig.mockReturnValueOnce({DefaultEnabled: true, AllowEnableCalls: true});
const successResult = await CallsActions.loadConfig('server1', false, 'Server Switch');
expect(successResult.data).toBeDefined();
expect(mockClient.getCallsConfig).toHaveBeenCalledWith('Server Switch');
assert.equal(result.current.DefaultEnabled, true);
assert.equal(result.current.AllowEnableCalls, true);
});
expect(mockClient.getCallsConfig).toHaveBeenCalledWith('Server Switch');
assert.equal(result.current.DefaultEnabled, true);
assert.equal(result.current.AllowEnableCalls, true);
// Test successful retrival from cache
await act(async () => {
expect(mockClient.getCallsConfig).toHaveBeenCalledTimes(1);
const successResult = await CallsActions.loadConfig('server1', false, 'Server Switch');
expect(successResult.data).toBeDefined();
expect(mockClient.getCallsConfig).toHaveBeenCalledTimes(1);
});
// Test error case
mockClient.getCallsConfig.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.loadConfig('server1', true, 'Server Switch');
expect(errorResult.error).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
it('enableChannelCalls', async () => {
const {result} = renderHook(() => useCallsState('server1'));
// Test successful case
assert.equal(result.current.enabled['channel-1'], undefined);
mockClient.enableChannelCalls.mockReturnValueOnce({enabled: true});
await act(async () => {
await CallsActions.enableChannelCalls('server1', 'channel-1', true);
const successResult = await CallsActions.enableChannelCalls('server1', 'channel-1', true);
expect(successResult).toEqual({});
expect(mockClient.enableChannelCalls).toHaveBeenCalledWith('channel-1', true);
assert.equal(result.current.enabled['channel-1'], true);
});
// Test error case
mockClient.enableChannelCalls.mockRejectedValueOnce(forceLogoutError);
await act(async () => {
const errorResult = await CallsActions.enableChannelCalls('server1', 'channel-1', true);
expect(errorResult.error).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
expect(mockClient.enableChannelCalls).toHaveBeenCalledWith('channel-1', true);
assert.equal(result.current.enabled['channel-1'], true);
});
it('disableChannelCalls', async () => {
@ -427,29 +537,710 @@ describe('Actions.Calls', () => {
it('startCallRecording', async () => {
await act(async () => {
await CallsActions.startCallRecording('server1', 'channel-id');
});
// Test successful case
const successResult = await CallsActions.startCallRecording('server1', 'channel-id');
expect(successResult).toBe(undefined);
expect(mockClient.startCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
expect(mockClient.startCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
// Test error case
mockClient.startCallRecording.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.startCallRecording('server1', 'channel-id');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('stopCallRecording', async () => {
await act(async () => {
await CallsActions.stopCallRecording('server1', 'channel-id');
});
// Test successful case
const successResult = await CallsActions.stopCallRecording('server1', 'channel-id');
expect(successResult).toBe(undefined);
expect(mockClient.stopCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
expect(needsRecordingWillBePostedAlert).toHaveBeenCalled();
expect(mockClient.stopCallRecording).toHaveBeenCalledWith('channel-id');
expect(needsRecordingErrorAlert).toHaveBeenCalled();
expect(needsRecordingWillBePostedAlert).toHaveBeenCalled();
// Test error case
mockClient.stopCallRecording.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.stopCallRecording('server1', 'channel-id');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('dismissIncomingCall', async () => {
// Test successful case
await act(async () => {
await CallsActions.dismissIncomingCall('server1', 'channel-id');
});
expect(mockClient.dismissCall).toHaveBeenCalledWith('channel-id');
// Test error case
mockClient.dismissCall.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.dismissIncomingCall('server1', 'channel-id');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
// Test when ringing is disabled
act(() => {
setCallsConfig('server1', {...DefaultCallsConfig, EnableRinging: false});
});
const result = await CallsActions.dismissIncomingCall('server1', 'channel-id');
expect(result).toEqual({});
expect(mockClient.dismissCall).toHaveBeenCalledTimes(2); // Called twice - success case and error case
});
it('hostMake', async () => {
// Test successful case
const successResult = await CallsActions.hostMake('server1', 'call1', 'user1');
expect(successResult).toBe(undefined);
expect(mockClient.hostMake).toHaveBeenCalledWith('call1', 'user1');
// Test error case
mockClient.hostMake.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostMake('server1', 'call1', 'user1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
it('hostMuteSession', async () => {
// Test successful case
const successfulResult = await CallsActions.hostMuteSession('server1', 'call1', 'session1');
expect(successfulResult).toBe(undefined);
expect(mockClient.hostMute).toHaveBeenCalledWith('call1', 'session1');
// Test error case
mockClient.hostMute.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostMuteSession('server1', 'call1', 'session1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
it('hostMuteOthers', async () => {
await act(async () => {
// Test successful case
const successResult = await CallsActions.hostMuteOthers('server1', 'call1');
expect(successResult).toBe(undefined);
expect(mockClient.hostMuteOthers).toHaveBeenCalledWith('call1');
// Test error case
mockClient.hostMuteOthers.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostMuteOthers('server1', 'call1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('hostStopScreenshare', async () => {
await act(async () => {
// Test successful case
const successResult = await CallsActions.hostStopScreenshare('server1', 'call1', 'session1');
expect(successResult).toBe(undefined);
expect(mockClient.hostScreenOff).toHaveBeenCalledWith('call1', 'session1');
// Test error case
mockClient.hostScreenOff.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostStopScreenshare('server1', 'call1', 'session1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('hostLowerHand', async () => {
await act(async () => {
// Test successful case
const successResult = await CallsActions.hostLowerHand('server1', 'call1', 'session1');
expect(successResult).toBe(undefined);
expect(mockClient.hostLowerHand).toHaveBeenCalledWith('call1', 'session1');
// Test error case
mockClient.hostLowerHand.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostLowerHand('server1', 'call1', 'session1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('hostRemove', async () => {
await act(async () => {
// Test successful case
const successResult = await CallsActions.hostRemove('server1', 'call1', 'session1');
expect(successResult).toBe(undefined);
expect(mockClient.hostRemove).toHaveBeenCalledWith('call1', 'session1');
// Test error case
mockClient.hostRemove.mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.hostRemove('server1', 'call1', 'session1');
expect(errorResult).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
describe('handleCallsSlashCommand', () => {
const intl = createIntl({locale: 'en', messages: {}});
beforeEach(() => {
jest.clearAllMocks();
act(() => {
setCallsState('server1', DefaultCallsState);
setChannelsWithCalls('server1', {});
setCurrentCall(null);
setCallsConfig('server1', DefaultCallsConfig);
});
});
it('should handle non-call commands', async () => {
const result = await CallsActions.handleCallsSlashCommand('/other command', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: false});
});
it('should handle /call without subcommand', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: false});
});
it('should handle end command', async () => {
const getCurrentUser = require('@queries/servers/user').getCurrentUser;
getCurrentUser.mockResolvedValueOnce({
id: 'user1',
roles: 'system_admin',
});
jest.spyOn(Alert, 'alert');
await CallsActions.handleCallsSlashCommand('/call end', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(Alert.alert).toHaveBeenCalled();
});
it('should handle start command in DM', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call start Test Call', 'server1', 'channel1', 'D', 'root1', 'user1', intl);
expect(result).toEqual({handled: true});
});
it('should handle start command with group calls disabled', async () => {
act(() => {
setCallsConfig('server1', {...DefaultCallsConfig, DefaultEnabled: false, AllowEnableCalls: false});
});
const result = await CallsActions.handleCallsSlashCommand('/call start', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle start command with existing call', async () => {
act(() => {
setChannelsWithCalls('server1', {channel1: true});
});
const result = await CallsActions.handleCallsSlashCommand('/call start', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle join command', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call join Test Call', 'server1', 'channel1', 'D', 'root1', 'user1', intl);
expect(result).toEqual({handled: true});
});
it('should handle leave command when in call', async () => {
act(() => {
newCurrentCall('server1', 'channel1', 'user1');
});
const result = await CallsActions.handleCallsSlashCommand('/call leave', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: true});
});
it('should handle leave command when not in call', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call leave', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
describe('recording commands', () => {
beforeEach(() => {
act(() => {
newCurrentCall('server1', 'channel1', 'user1');
});
});
describe('handleEndCall', () => {
beforeEach(() => {
jest.spyOn(Alert, 'alert');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should show error when user lacks permission', async () => {
const getCurrentUser = require('@queries/servers/user').getCurrentUser;
getCurrentUser.mockResolvedValueOnce({
id: 'user2',
roles: 'user',
});
act(() => {
State.setCallsState('server1', {
myUserId: 'user2',
calls: {
channel1: {
hostId: 'user1',
} as Call,
},
enabled: {},
});
});
await CallsActions.handleCallsSlashCommand('/call end', 'server1', 'channel1', 'O', '', 'user2', intl);
expect(Alert.alert).toHaveBeenCalledWith(
'Error',
'You don\'t have permission to end the call. Please ask the call owner to end the call.',
);
expect(mockClient.endCall).not.toHaveBeenCalled();
});
it('should end call when user has permission', async () => {
const getCurrentUser = require('@queries/servers/user').getCurrentUser;
getCurrentUser.mockResolvedValueOnce({
id: 'user1',
roles: 'system_admin',
});
const getChannelById = require('@queries/servers/channel').getChannelById;
getChannelById.mockResolvedValueOnce({
id: 'channel1',
type: 'O',
displayName: 'Test Channel',
});
act(() => {
State.setCallsState('server1', {
myUserId: 'user1',
calls: {
channel1: {
id: 'call1',
sessions: {
session1: {
sessionId: 'session1',
userId: 'user1',
muted: false,
raisedHand: 0,
},
session2: {
sessionId: 'session2',
userId: 'user2',
muted: true,
raisedHand: 0,
},
},
channelId: 'channel1',
startTime: (new Date()).getTime(),
screenOn: '',
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
hostId: 'xohi8cki9787fgiryne716u84o',
dismissed: {},
},
},
enabled: {},
});
});
await CallsActions.handleCallsSlashCommand('/call end', 'server1', 'channel1', 'O', '', 'user1', intl);
// Get the callback from the second button
const alertCallback = (Alert.alert as jest.Mock).mock.calls[0][2][1].onPress;
await alertCallback();
expect(Alert.alert).toHaveBeenCalled();
expect(mockClient.endCall).toHaveBeenCalledWith('channel1');
});
});
it('should handle recording without action', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call recording', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: false});
});
it('should handle recording start when already recording', async () => {
act(() => {
setCurrentCall({
...getCurrentCall()!,
recState: {
init_at: 1,
start_at: 2,
end_at: 1,
} as CallJobState,
});
});
const result = await CallsActions.handleCallsSlashCommand('/call recording start', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle recording start without host permission', async () => {
act(() => {
setCurrentCall({
...getCurrentCall()!,
hostId: 'other_user',
});
});
const result = await CallsActions.handleCallsSlashCommand('/call recording start', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle recording start as host', async () => {
act(() => {
setCurrentCall({
...getCurrentCall()!,
hostId: 'user1',
});
});
const result = await CallsActions.handleCallsSlashCommand('/call recording start', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: true});
expect(mockClient.startCallRecording).toHaveBeenCalled();
});
it('should handle recording stop when not recording', async () => {
const result = await CallsActions.handleCallsSlashCommand('/call recording stop', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle recording stop without host permission', async () => {
act(() => {
setCurrentCall({
...getCurrentCall()!,
hostId: 'other_user',
recState: {
init_at: 1,
start_at: 2,
end_at: 1,
} as CallJobState,
});
});
const result = await CallsActions.handleCallsSlashCommand('/call recording stop', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result.error).toBeDefined();
expect(result.handled).toBeUndefined();
});
it('should handle recording stop as host', async () => {
act(() => {
setCurrentCall({
...getCurrentCall()!,
hostId: 'user1',
recState: {
init_at: 1,
start_at: 2,
end_at: 1,
} as CallJobState,
});
});
const result = await CallsActions.handleCallsSlashCommand('/call recording stop', 'server1', 'channel1', 'O', '', 'user1', intl);
expect(result).toEqual({handled: true});
expect(mockClient.stopCallRecording).toHaveBeenCalled();
});
});
});
it('loadCallForChannel', async () => {
const {result} = renderHook(() => {
return [useCallsState('server1'), useChannelsWithCalls('server1')];
});
// Test successful case
await act(async () => {
mockClient.getCallForChannel.mockReturnValueOnce({
call: {
channel_id: 'channel-1',
users: ['user-1'],
},
enabled: true});
const successResult = await CallsActions.loadCallForChannel('server1', 'channel-1');
expect(successResult.data).toBeDefined();
expect(mockClient.getCallForChannel).toHaveBeenCalled();
assert.equal((result.current[0].calls as Dictionary<Call>)['channel-1'].channelId, 'channel-1');
assert.equal((result.current[0].enabled as Dictionary<boolean>)['channel-1'], true);
});
// Test error case
mockClient.getCallForChannel.mockRejectedValueOnce(forceLogoutError);
await act(async () => {
const errorResult = await CallsActions.loadCallForChannel('server1', 'channel-1');
expect(errorResult.error).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
});
it('loadConfigAndCalls', async () => {
// Test successful case - plugin enabled
const successResult = await CallsActions.loadConfigAndCalls('server1', 'user1');
expect(successResult).toBeUndefined();
expect(mockClient.getPluginsManifests).toHaveBeenCalled();
expect(mockClient.getCallsConfig).toHaveBeenCalledTimes(1);
expect(mockClient.getCalls).toHaveBeenCalledTimes(1);
// Test when plugin is disabled
mockClient.getPluginsManifests.mockReturnValueOnce([{id: 'other-plugin'}]);
const disabledResult = await CallsActions.loadConfigAndCalls('server1', 'user1');
expect(disabledResult).toBeUndefined();
expect(mockClient.getCallsConfig).toHaveBeenCalledTimes(1);
expect(mockClient.getCalls).toHaveBeenCalledTimes(1);
});
it('checkIsCallsPluginEnabled', async () => {
// Test successful case - plugin enabled
const successResult = await CallsActions.checkIsCallsPluginEnabled('server1');
expect(successResult.data).toBe(true);
expect(mockClient.getPluginsManifests).toHaveBeenCalled();
// Test successful case - plugin disabled
mockClient.getPluginsManifests.mockReturnValueOnce([{id: 'other-plugin'}]);
const disabledResult = await CallsActions.checkIsCallsPluginEnabled('server1');
expect(disabledResult.data).toBe(false);
// Test error case
mockClient.getPluginsManifests = jest.fn().mockRejectedValueOnce(forceLogoutError);
const errorResult = await CallsActions.checkIsCallsPluginEnabled('server1');
expect(errorResult.error).toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
it('canEndCall', async () => {
// Test when server cannot be found.
const result1 = await CallsActions.canEndCall('server2', 'channel-1');
expect(result1).toBe(false);
await act(() => {
State.setCallsState('server1', {
myUserId: 'user1',
calls: {
'channel-1': {
hostId: 'user1',
} as Call,
},
enabled: {},
});
});
// Test when current user is system admin
const getCurrentUser = require('@queries/servers/user').getCurrentUser;
getCurrentUser.mockResolvedValueOnce({
id: 'user1',
roles: 'system_admin',
});
const result2 = await CallsActions.canEndCall('server1', 'channel-1');
expect(result2).toBe(true);
// Test when current user is call host
getCurrentUser.mockResolvedValueOnce({
id: 'user1',
roles: 'user',
});
const result3 = await CallsActions.canEndCall('server1', 'channel-1');
expect(result3).toBe(true);
// Test when current user is neither admin nor host
getCurrentUser.mockResolvedValueOnce({
id: 'user2',
roles: 'user',
});
const result4 = await CallsActions.canEndCall('server1', 'channel-1');
expect(result4).toBe(false);
});
it('getEndCallMessage', async () => {
const intl = createIntl({
locale: 'en',
messages: {},
});
const getChannelById = require('@queries/servers/channel').getChannelById;
const getUserById = require('@queries/servers/user').getUserById;
const getLicense = require('@queries/servers/system').getLicense;
const getConfig = require('@queries/servers/system').getConfig;
const queryDisplayNamePreferences = require('@queries/servers/preference').queryDisplayNamePreferences;
// Test when server cannot be found.
const result1 = await CallsActions.getEndCallMessage('server2', 'channel-1', 'user1', intl);
expect(result1).toContain('Are you sure you want to end the call?');
// Test regular channel
getChannelById.mockResolvedValueOnce({
id: 'channel-1',
type: 'O',
displayName: 'Test Channel',
});
act(() => {
State.setCallsState('server1', {
myUserId: 'user1',
calls: {
'channel-1': {
id: 'call1',
sessions: {
session1: {
sessionId: 'a23456abcdefghijklmnopqrs',
userId: 'xohi8cki9787fgiryne716u84o',
muted: false,
raisedHand: 0,
},
session2: {
sessionId: 'a12345667890bcdefghijklmn1',
userId: 'xohi8cki9787fgiryne716u84o',
muted: true,
raisedHand: 0,
},
},
channelId: 'channel-1',
startTime: (new Date()).getTime(),
screenOn: '',
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
hostId: 'xohi8cki9787fgiryne716u84o',
dismissed: {},
},
},
enabled: {},
});
});
const result2 = await CallsActions.getEndCallMessage('server1', 'channel-1', 'user1', intl);
expect(result2).toContain('2 participants');
expect(result2).toContain('Test Channel');
act(() => {
State.setCallsState('server1', {
myUserId: 'user1',
calls: {
'channel-2': {
id: 'call1',
sessions: {
session1: {
sessionId: 'a23456abcdefghijklmnopqrs',
userId: 'xohi8cki9787fgiryne716u84o',
muted: false,
raisedHand: 0,
},
session2: {
sessionId: 'a12345667890bcdefghijklmn1',
userId: 'xohi8cki9787fgiryne716u84o',
muted: true,
raisedHand: 0,
},
},
channelId: 'channel-2',
startTime: (new Date()).getTime(),
screenOn: '',
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
hostId: 'xohi8cki9787fgiryne716u84o',
dismissed: {},
},
},
enabled: {},
});
});
// Test DM channel
getChannelById.mockResolvedValueOnce({
id: 'channel-2',
type: 'D',
name: 'user1__user2',
displayName: 'User Two',
});
getUserById.mockResolvedValueOnce({
id: 'user2',
username: 'user2',
firstName: 'User',
lastName: 'Two',
});
getLicense.mockResolvedValueOnce({});
getConfig.mockResolvedValueOnce({
TeammateNameDisplay: 'username',
});
queryDisplayNamePreferences.mockReturnValueOnce({
fetch: () => Promise.resolve([]),
});
const result3 = await CallsActions.getEndCallMessage('server1', 'channel-2', 'user1', intl);
expect(result3).toBe('Are you sure you want to end the call with user2?');
});
it('endCall', async () => {
// Test successful case
const successResult = await CallsActions.endCall('server1', 'channel-1');
expect(successResult).toBe(undefined);
expect(mockClient.endCall).toHaveBeenCalledWith('channel-1');
// Test error case
mockClient.endCall.mockRejectedValueOnce(forceLogoutError);
await expect(CallsActions.endCall('server1', 'channel-1')).rejects.toBe(forceLogoutError);
expect(forceLogout).toHaveBeenCalledWith('server1', forceLogoutError);
});
it('setPreferredAudioRoute', async () => {
await CallsActions.setPreferredAudioRoute(AudioDevice.Speakerphone);
expect(InCallManager.chooseAudioRoute).toHaveBeenCalledWith('SPEAKER_PHONE');
});
it('initializeVoiceTrack', async () => {
renderHook(() => useCurrentCall());
addFakeCall('server1', 'channel-id');
await act(async () => {
await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
newCurrentCall('server1', 'channel-id', 'myUserId');
});
CallsActions.initializeVoiceTrack();
expect(getConnectionForTesting()?.initializeVoiceTrack).toHaveBeenCalled();
});
it('sendReaction', async () => {
renderHook(() => useCurrentCall());
addFakeCall('server1', 'channel-id');
await act(async () => {
await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
newCurrentCall('server1', 'channel-id', 'myUserId');
});
const emoji = {name: 'smile', unified: '1f604'};
CallsActions.sendReaction(emoji);
expect(getConnectionForTesting()?.sendReaction).toHaveBeenCalledWith(emoji);
});
it('userLeftChannelErr', async () => {

View file

@ -669,7 +669,7 @@ const handleEndCall = async (serverUrl: string, channelId: string, currentUserId
export const hostMake = async (serverUrl: string, callId: string, newHostId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostMake(callId, newHostId);
return await client.hostMake(callId, newHostId);
} catch (error) {
logDebug('error on hostMake', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
@ -680,7 +680,7 @@ export const hostMake = async (serverUrl: string, callId: string, newHostId: str
export const hostMuteSession = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostMute(callId, sessionId);
return await client.hostMute(callId, sessionId);
} catch (error) {
logDebug('error on hostMute', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
@ -691,7 +691,7 @@ export const hostMuteSession = async (serverUrl: string, callId: string, session
export const hostMuteOthers = async (serverUrl: string, callId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostMuteOthers(callId);
return await client.hostMuteOthers(callId);
} catch (error) {
logDebug('error on hostMuteOthers', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
@ -702,7 +702,7 @@ export const hostMuteOthers = async (serverUrl: string, callId: string) => {
export const hostStopScreenshare = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostScreenOff(callId, sessionId);
return await client.hostScreenOff(callId, sessionId);
} catch (error) {
logDebug('error on hostStopScreenshare', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
@ -713,7 +713,7 @@ export const hostStopScreenshare = async (serverUrl: string, callId: string, ses
export const hostLowerHand = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostLowerHand(callId, sessionId);
return await client.hostLowerHand(callId, sessionId);
} catch (error) {
logDebug('error on hostLowerHand', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
@ -724,7 +724,7 @@ export const hostLowerHand = async (serverUrl: string, callId: string, sessionId
export const hostRemove = async (serverUrl: string, callId: string, sessionId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
return client.hostRemove(callId, sessionId);
return await client.hostRemove(callId, sessionId);
} catch (error) {
logDebug('error on hostRemove', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);

View file

@ -16,6 +16,21 @@ export {
startCallRecording,
stopCallRecording,
dismissIncomingCall,
hostMake,
hostMuteSession,
hostMuteOthers,
hostStopScreenshare,
hostLowerHand,
hostRemove,
setPreferredAudioRoute,
initializeVoiceTrack,
sendReaction,
endCall,
loadCallForChannel,
loadConfigAndCalls,
checkIsCallsPluginEnabled,
canEndCall,
getEndCallMessage,
} from './calls';
export {hasMicrophonePermission} from './permissions';

View file

@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import Permissions from 'react-native-permissions';
import {hasBluetoothPermission, hasMicrophonePermission} from './permissions';
jest.mock('react-native-permissions', () => ({
PERMISSIONS: {
IOS: {
BLUETOOTH: 'ios.bluetooth',
MICROPHONE: 'ios.microphone',
},
ANDROID: {
BLUETOOTH_CONNECT: 'android.bluetooth_connect',
RECORD_AUDIO: 'android.record_audio',
},
},
RESULTS: {
DENIED: 'denied',
GRANTED: 'granted',
BLOCKED: 'blocked',
UNAVAILABLE: 'unavailable',
},
check: jest.fn(),
request: jest.fn(),
}));
describe('Permissions', () => {
beforeEach(() => {
jest.clearAllMocks();
Platform.select = jest.fn((options) => options.ios);
});
describe('hasBluetoothPermission', () => {
it('should return true when permission is granted', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.GRANTED);
const result = await hasBluetoothPermission();
expect(result).toBe(true);
});
it('should request permission when denied', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.DENIED);
jest.mocked(Permissions.request).mockResolvedValue(Permissions.RESULTS.GRANTED);
const result = await hasBluetoothPermission();
expect(result).toBe(true);
expect(Permissions.request).toHaveBeenCalled();
});
it('should return false when blocked', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.BLOCKED);
const result = await hasBluetoothPermission();
expect(result).toBe(false);
});
it('should handle Android permissions', async () => {
Platform.select = jest.fn((options: Record<string, unknown>) => options.default);
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.GRANTED);
await hasBluetoothPermission();
expect(Permissions.check).toHaveBeenCalledWith(Permissions.PERMISSIONS.ANDROID.BLUETOOTH_CONNECT);
});
});
describe('hasMicrophonePermission', () => {
it('should return true when permission is granted', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.GRANTED);
const result = await hasMicrophonePermission();
expect(result).toBe(true);
});
it('should request permission when denied', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.DENIED);
jest.mocked(Permissions.request).mockResolvedValue(Permissions.RESULTS.GRANTED);
const result = await hasMicrophonePermission();
expect(result).toBe(true);
expect(Permissions.request).toHaveBeenCalled();
});
it('should return false when blocked', async () => {
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.BLOCKED);
const result = await hasMicrophonePermission();
expect(result).toBe(false);
});
it('should handle Android permissions', async () => {
Platform.select = jest.fn((options: Record<string, unknown>) => options.default);
jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.GRANTED);
await hasMicrophonePermission();
expect(Permissions.check).toHaveBeenCalledWith(Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO);
});
});
});

View file

@ -0,0 +1,503 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert} from 'react-native';
import {hostRemove} from '@calls/actions/calls';
import {EndCallReturn} from '@calls/types/calls';
import DatabaseManager from '@database/manager';
import {dismissBottomSheet} from '@screens/navigation';
import {
showLimitRestrictedAlert,
recordingAlert,
stopRecordingConfirmationAlert,
recordingWillBePostedAlert,
recordingErrorAlert,
removeFromCall,
endCallConfirmationAlert,
needsRecordingAlert,
needsRecordingWillBePostedAlert,
needsRecordingErrorAlert,
leaveAndJoinWithAlert,
} from './alerts';
jest.mock('@calls/actions', () => ({
leaveCall: jest.fn(),
joinCall: jest.fn().mockResolvedValue({}),
hasMicrophonePermission: jest.fn().mockResolvedValue(true),
unmuteMyself: jest.fn(),
dismissIncomingCall: jest.fn(),
removeIncomingCall: jest.fn(),
}));
jest.mock('@calls/actions/permissions', () => ({
hasBluetoothPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('@calls/state', () => ({
getCallsState: jest.fn(),
getChannelsWithCalls: jest.fn(),
getCurrentCall: jest.fn(),
getCallsConfig: jest.fn(),
setMicPermissionsGranted: jest.fn(),
}));
jest.mock('@calls/actions/calls');
jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
}));
jest.mock('@queries/servers/channel');
jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(() => Promise.resolve({
username: 'user-1',
})),
getCurrentUser: jest.fn().mockResolvedValue({
username: 'user-1',
roles: 'system_user',
}),
}));
describe('alerts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('showLimitRestrictedAlert', () => {
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}, values?: any) => {
if (values) {
return defaultMessage.replace('{maxParticipants}', values.maxParticipants);
}
return defaultMessage;
},
};
it('shows alert for cloud starter', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
showLimitRestrictedAlert({
limitRestricted: true,
maxParticipants: 8,
isCloudStarter: true,
}, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'This call is at capacity',
'Upgrade to Cloud Professional or Cloud Enterprise to enable group calls with more than 8 participants.',
[
{
text: 'Okay',
style: 'cancel',
},
],
);
});
it('shows alert for non-cloud starter', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
showLimitRestrictedAlert({
limitRestricted: true,
maxParticipants: 8,
isCloudStarter: false,
}, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'This call is at capacity',
'The maximum number of participants per call is 8. Contact your System Admin to increase the limit.',
[
{
text: 'Okay',
style: 'cancel',
},
],
);
});
});
describe('recordingAlert', () => {
beforeEach(() => {
needsRecordingAlert();
});
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
};
it('shows host recording alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(true, false, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'You are recording',
'Consider letting everyone know that this meeting is being recorded.',
[{
text: 'Dismiss',
}],
);
});
it('should not show recording alert twice', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(true, false, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'You are recording',
'Consider letting everyone know that this meeting is being recorded.',
[{
text: 'Dismiss',
}],
);
mockAlert.mockClear();
recordingAlert(true, false, intl as any);
expect(mockAlert).not.toHaveBeenCalled();
});
it('shows participant recording alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(false, false, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Recording is in progress',
'The host has started recording this meeting. By staying in the meeting you give consent to being recorded.',
[
{
text: 'Leave',
onPress: expect.any(Function),
style: 'destructive',
},
{
text: 'Okay',
style: 'default',
},
],
);
});
it('shows host transcription alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(true, true, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Recording and transcription has started',
'Consider letting everyone know that this meeting is being recorded and transcribed.',
[{
text: 'Dismiss',
}],
);
});
it('shows participant transcription alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(false, true, intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Recording and transcription is in progress',
'The host has started recording and transcription for this meeting. By staying in the meeting, you give consent to being recorded and transcribed.',
[
{
text: 'Leave',
onPress: expect.any(Function),
style: 'destructive',
},
{
text: 'Okay',
style: 'default',
},
],
);
});
it('calls leaveCall when Leave is pressed', () => {
const {leaveCall} = require('@calls/actions');
const mockAlert = jest.spyOn(Alert, 'alert');
recordingAlert(false, false, intl as any);
const leaveButton = mockAlert.mock.calls[0][2]![0];
leaveButton.onPress?.();
expect(leaveCall).toHaveBeenCalled();
});
});
describe('stopRecordingConfirmationAlert', () => {
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
};
it('shows recording confirmation alert', async () => {
const mockAlert = jest.spyOn(Alert, 'alert');
const promise = stopRecordingConfirmationAlert(intl as any, false);
expect(mockAlert).toHaveBeenCalledWith(
'Stop recording',
'The call recording will be processed and posted in the call thread. Are you sure you want to stop the recording?',
[
{
text: 'Cancel',
onPress: expect.any(Function),
style: 'cancel',
},
{
text: 'Stop recording',
onPress: expect.any(Function),
style: 'destructive',
},
],
);
const confirmButton = mockAlert.mock.calls[0][2]![1];
confirmButton.onPress?.();
expect(await promise).toBe(true);
});
it('shows transcription confirmation alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
stopRecordingConfirmationAlert(intl as any, true);
expect(mockAlert).toHaveBeenCalledWith(
'Stop recording and transcription',
'The call recording and transcription files will be processed and posted in the call thread. Are you sure you want to stop the recording and transcription?',
expect.any(Array),
);
});
});
describe('recordingWillBePostedAlert', () => {
beforeEach(() => {
needsRecordingWillBePostedAlert();
});
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
};
it('shows recording posted alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingWillBePostedAlert(intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Recording has stopped. Processing...',
'You can find the recording in this call\'s chat thread once it\'s finished processing.',
[{
text: 'Dismiss',
}],
);
});
});
describe('recordingErrorAlert', () => {
beforeEach(() => {
needsRecordingErrorAlert();
});
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
};
it('shows recording error alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
recordingErrorAlert(intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Something went wrong with the recording',
'Please try to record again. You can also contact your system admin for troubleshooting help.',
[{
text: 'Dismiss',
}],
);
});
});
describe('removeFromCall', () => {
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}, values?: any) => {
if (values) {
return defaultMessage.replace('{displayName}', values.displayName);
}
return defaultMessage;
},
};
it('shows remove confirmation and removes user', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
removeFromCall('server1', 'test-user', 'call1', 'session1', intl as any);
expect(mockAlert).toHaveBeenCalledWith(
'Remove participant',
'Are you sure you want to remove test-user from the call? ',
[
{
text: 'Cancel',
},
{
text: 'Remove',
onPress: expect.any(Function),
style: 'destructive',
},
],
);
const removeButton = mockAlert.mock.calls[0][2]![1];
removeButton.onPress?.();
expect(hostRemove).toHaveBeenCalledWith('server1', 'call1', 'session1');
expect(dismissBottomSheet).toHaveBeenCalled();
});
});
describe('leaveAndJoinWithAlert', () => {
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}, values?: any) => {
if (values) {
return defaultMessage.replace('{leaveChannelName}', values.leaveChannelName).replace('{joinChannelName}', values.joinChannelName);
}
return defaultMessage;
},
};
beforeAll(async () => {
await DatabaseManager.init(['server1']);
});
beforeEach(() => {
jest.clearAllMocks();
const {getChannelById} = require('@queries/servers/channel');
getChannelById.mockImplementation((db: any, channelId: string) => {
if (channelId === 'join-channel') {
return {displayName: 'join-channel', type: 'D'};
} else if (channelId === 'leave-channel') {
return {displayName: 'leave-channel'};
}
return null;
});
});
it('joins new call when not in a call', async () => {
const {getCallsState, getChannelsWithCalls, getCurrentCall, getCallsConfig, setMicPermissionsGranted} = require('@calls/state');
getCallsState.mockReturnValue({
enabled: {
'join-channel': true,
},
});
getChannelsWithCalls.mockReturnValue({});
getCurrentCall.mockReturnValue(null);
getCallsConfig.mockReturnValue({});
setMicPermissionsGranted.mockReturnValue(true);
const mockAlert = jest.spyOn(Alert, 'alert');
const result = await leaveAndJoinWithAlert(intl as any, 'server1', 'join-channel');
expect(mockAlert).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it('shows leave and join confirmation when in a call', async () => {
const {getCallsState, getChannelsWithCalls, getCurrentCall, getCallsConfig, setMicPermissionsGranted} = require('@calls/state');
getCallsState.mockReturnValue({
enabled: {
'join-channel': true,
},
});
getChannelsWithCalls.mockReturnValue({
'leave-channel': 'call1',
'join-channel': 'call2',
});
getCurrentCall.mockReturnValue({
serverUrl: 'server1',
channelId: 'leave-channel',
});
getCallsConfig.mockReturnValue({});
setMicPermissionsGranted.mockReturnValue(true);
const mockAlert = jest.spyOn(Alert, 'alert').mockImplementationOnce(async (title, message, buttons) => {
// Simulate cancel
await buttons![0].onPress?.();
});
expect(await leaveAndJoinWithAlert(intl as any, 'server1', 'join-channel')).toBe(false);
console.log('rar');
expect(mockAlert).toHaveBeenCalledWith(
'Are you sure you want to switch to a different call?',
'You are already on a channel call in ~leave-channel. Do you want to leave your current call and join the call in ~join-channel?',
[
{
text: 'Cancel',
onPress: expect.any(Function),
style: 'destructive',
},
{
text: 'Leave & Join',
onPress: expect.any(Function),
isPreferred: true,
},
],
);
});
it('shows leave and start confirmation when starting new call', async () => {
const {getCallsState, getChannelsWithCalls, getCurrentCall, getCallsConfig, setMicPermissionsGranted} = require('@calls/state');
getCallsState.mockReturnValue({
enabled: {
'join-channel': true,
},
});
getChannelsWithCalls.mockReturnValue({
'leave-channel': 'call1',
});
getCurrentCall.mockReturnValue({
serverUrl: 'server1',
channelId: 'leave-channel',
});
getCallsConfig.mockReturnValue({});
setMicPermissionsGranted.mockReturnValue(true);
const mockAlert = jest.spyOn(Alert, 'alert').mockImplementationOnce((title, message, buttons) => {
// Simulate confirm
buttons![1].onPress?.();
});
expect(await leaveAndJoinWithAlert(intl as any, 'server1', 'join-channel')).toBe(true);
expect(mockAlert).toHaveBeenCalledWith(
'Are you sure you want to switch to a different call?',
'You are already on a channel call in ~leave-channel. Do you want to leave your current call and start a new call in ~join-channel?',
expect.any(Array),
);
});
});
describe('endCallConfirmationAlert', () => {
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}) => defaultMessage,
};
it('shows end call confirmation for non-host', async () => {
const mockAlert = jest.spyOn(Alert, 'alert');
const promise = endCallConfirmationAlert(intl as any, false);
expect(mockAlert).toHaveBeenCalledWith(
'Are you sure you want to leave this call?',
'',
expect.any(Array),
);
// Simulate leave
const leaveButton = mockAlert.mock.calls[0][2]![1];
leaveButton.onPress?.();
expect(await promise).toBe(EndCallReturn.LeaveCall);
});
it('shows end call confirmation for host', async () => {
const mockAlert = jest.spyOn(Alert, 'alert');
const promise = endCallConfirmationAlert(intl as any, true);
const buttons = mockAlert.mock.calls[0][2]!;
expect(buttons).toHaveLength(3);
expect(buttons.map((b) => b.text)).toEqual(['Cancel', 'End call for everyone', 'Leave call']);
// Simulate end call
const endButton = buttons[1];
endButton.onPress?.();
expect(await promise).toBe(EndCallReturn.EndCall);
});
});
});

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, Platform} from 'react-native';
import {callsOnAppStateChange} from '@calls/state';
import {CallsManager} from './calls_manager';
jest.mock('@calls/state', () => ({
callsOnAppStateChange: jest.fn(),
}));
describe('CallsManager', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(AppState, 'addEventListener');
});
afterEach(() => {
// Reset platform to its original value
Platform.OS = 'ios';
});
it('initializes AppState listeners correctly for iOS', () => {
Platform.OS = 'ios';
CallsManager.initialize();
expect(AppState.addEventListener).toHaveBeenCalledWith('change', callsOnAppStateChange);
expect(AppState.addEventListener).toHaveBeenCalledTimes(1);
});
it('initializes AppState listeners correctly for Android', () => {
Platform.OS = 'android';
CallsManager.initialize();
expect(AppState.addEventListener).toHaveBeenCalledWith('blur', expect.any(Function));
expect(AppState.addEventListener).toHaveBeenCalledWith('focus', expect.any(Function));
expect(AppState.addEventListener).toHaveBeenCalledTimes(2);
// Test that the blur callback calls callsOnAppStateChange with 'inactive'
const blurCallback = jest.mocked(AppState.addEventListener).mock.calls[0][1];
blurCallback('inactive');
expect(callsOnAppStateChange).toHaveBeenCalledWith('inactive');
// Test that the focus callback calls callsOnAppStateChange with 'active'
const focusCallback = jest.mocked(AppState.addEventListener).mock.calls[1][1];
focusCallback('active');
expect(callsOnAppStateChange).toHaveBeenCalledWith('active');
});
});

View file

@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import clientCalls from './rest';
describe('ClientCalls', () => {
const mockDoFetch = jest.fn();
const mockGetCallsRoute = jest.fn(() => '/plugins/com.plugins.calls');
const BaseClass = class {
doFetch = mockDoFetch;
getCallsRoute = mockGetCallsRoute;
};
const Client = clientCalls(BaseClass);
const client = new Client();
beforeEach(() => {
jest.clearAllMocks();
});
describe('getEnabled', () => {
it('returns true when version check succeeds', async () => {
mockDoFetch.mockResolvedValueOnce({});
const result = await client.getEnabled();
expect(result).toBe(true);
expect(mockDoFetch).toHaveBeenCalledWith('/plugins/com.plugins.calls/version', {method: 'get'});
});
it('returns false when version check fails', async () => {
mockDoFetch.mockRejectedValueOnce(new Error());
const result = await client.getEnabled();
expect(result).toBe(false);
});
});
describe('getCalls', () => {
it('makes correct API call', async () => {
await client.getCalls();
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/channels?mobilev2=true',
{method: 'get', groupLabel: undefined},
);
});
it('passes groupLabel when provided', async () => {
await client.getCalls('Server Switch');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/channels?mobilev2=true',
{method: 'get', groupLabel: 'Server Switch'},
);
});
});
describe('getCallForChannel', () => {
it('makes correct API call', async () => {
await client.getCallForChannel('channel-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/channel-id?mobilev2=true',
{method: 'get'},
);
});
});
describe('enableChannelCalls', () => {
it('makes correct API call to enable', async () => {
await client.enableChannelCalls('channel-id', true);
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/channel-id',
{method: 'post', body: {enabled: true}},
);
});
it('makes correct API call to disable', async () => {
await client.enableChannelCalls('channel-id', false);
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/channel-id',
{method: 'post', body: {enabled: false}},
);
});
});
describe('endCall', () => {
it('makes correct API call', async () => {
await client.endCall('channel-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/channel-id/end',
{method: 'post'},
);
});
});
describe('host actions', () => {
it('makes correct API call for hostMake', async () => {
await client.hostMake('call-id', 'new-host-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/make',
{method: 'post', body: {new_host_id: 'new-host-id'}},
);
});
it('makes correct API call for hostMute', async () => {
await client.hostMute('call-id', 'session-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/mute',
{method: 'post', body: {session_id: 'session-id'}},
);
});
it('makes correct API call for hostMuteOthers', async () => {
await client.hostMuteOthers('call-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/mute-others',
{method: 'post'},
);
});
it('makes correct API call for hostScreenOff', async () => {
await client.hostScreenOff('call-id', 'session-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/screen-off',
{method: 'post', body: {session_id: 'session-id'}},
);
});
it('makes correct API call for hostLowerHand', async () => {
await client.hostLowerHand('call-id', 'session-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/lower-hand',
{method: 'post', body: {session_id: 'session-id'}},
);
});
it('makes correct API call for hostRemove', async () => {
await client.hostRemove('call-id', 'session-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/host/remove',
{method: 'post', body: {session_id: 'session-id'}},
);
});
});
describe('getCallsConfig', () => {
it('makes correct API call', async () => {
await client.getCallsConfig();
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/config',
{method: 'get', groupLabel: undefined},
);
});
it('passes groupLabel when provided', async () => {
await client.getCallsConfig('Server Switch');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/config',
{method: 'get', groupLabel: 'Server Switch'},
);
});
});
describe('getVersion', () => {
it('makes correct API call', async () => {
await client.getVersion();
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/version',
{method: 'get', groupLabel: undefined},
);
});
it('passes groupLabel when provided', async () => {
await client.getVersion('Server Switch');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/version',
{method: 'get', groupLabel: 'Server Switch'},
);
});
it('returns empty object on error', async () => {
mockDoFetch.mockRejectedValueOnce(new Error());
const result = await client.getVersion();
expect(result).toEqual({});
});
});
describe('genTURNCredentials', () => {
it('makes correct API call', async () => {
await client.genTURNCredentials();
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/turn-credentials',
{method: 'get'},
);
});
});
describe('recording actions', () => {
it('makes correct API call for startCallRecording', async () => {
await client.startCallRecording('call-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/recording/start',
{method: 'post'},
);
});
it('makes correct API call for stopCallRecording', async () => {
await client.stopCallRecording('call-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/call-id/recording/stop',
{method: 'post'},
);
});
});
describe('dismissCall', () => {
it('makes correct API call', async () => {
await client.dismissCall('channel-id');
expect(mockDoFetch).toHaveBeenCalledWith(
'/plugins/com.plugins.calls/calls/channel-id/dismiss-notification',
{method: 'post'},
);
});
});
});

View file

@ -61,7 +61,7 @@ const ClientCalls = (superclass: any) => class extends superclass {
getVersion = async (groupLabel?: RequestGroupLabel) => {
try {
return this.doFetch(
return await this.doFetch(
`${this.getCallsRoute()}/version`,
{method: 'get', groupLabel},
);

View file

@ -1,21 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RTCMonitor, RTCPeer, parseRTCStats} from '@mattermost/calls/lib';
import {zlibSync, strToU8} from 'fflate';
import {Platform} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import NetworkManager from '@managers/network_manager';
import {newConnection} from './connection';
import {WebSocketClient} from './websocket_client';
import {WebSocketClient, wsReconnectionTimeoutErr} from './websocket_client';
jest.mock('./websocket_client');
jest.mock('@mattermost/calls/lib');
jest.mock('react-native-webrtc', () => ({
registerGlobals: jest.fn(),
mediaDevices: {
getUserMedia: jest.fn().mockResolvedValue({
getAudioTracks: () => [{
id: 'audioTrackId',
enabled: true,
}],
getTracks: () => [{
id: 'audioTrackId',
enabled: true,
stop: jest.fn(),
release: jest.fn(),
}],
}),
},
}));
jest.mock('@calls/connection/foreground_service', () => ({
foregroundServiceStart: jest.fn(),
foregroundServiceStop: jest.fn(),
foregroundServiceSetup: jest.fn(),
}));
describe('newConnection', () => {
const mockClient = {
getWebSocketUrl: jest.fn(() => 'ws://localhost:8065'),
getCallsConfig: jest.fn(() => ({})),
getCallsConfig: jest.fn(() => ({
ICEServers: ['stun:stun.example.com'],
ICEServersConfigs: [{urls: ['stun:stun.example.com']}],
AllowEnableCalls: true,
EnableAV1: true,
})),
genTURNCredentials: jest.fn(() => Promise.resolve([{
urls: ['turn:turn.example.com'],
username: 'user',
credential: 'pass',
}])),
};
const mockRTCStats = {
@ -65,12 +98,16 @@ describe('newConnection', () => {
RTCMonitor.mockImplementation(() => {
return {
on: jest.fn(),
clearCache: jest.fn(),
stop: jest.fn(),
start: jest.fn(),
};
});
Platform.OS = 'web';
Platform.OS = 'android';
InCallManager.start = jest.fn();
InCallManager.stop = jest.fn();
InCallManager.stopProximitySensor = jest.fn();
});
@ -82,7 +119,384 @@ describe('newConnection', () => {
jest.resetAllMocks();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('join', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: jest.fn(),
getStats: jest.fn(),
}));
let openHandler;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
} else if (event === 'open') {
openHandler = handler;
handler();
}
},
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
expect(wsSend).toHaveBeenCalledWith('join', {av1Support: false, channelID: 'channelID'});
// reconnect
expect(openHandler).toBeDefined();
openHandler!('originalConnID', 'prevConnID', true);
expect(wsSend).toHaveBeenCalledWith('reconnect', {channelID: 'channelID', originalConnID: 'originalConnID', prevConnID: 'prevConnID'});
});
it('mute/unmute', async () => {
const mockReplaceTrack = jest.fn();
const mockAddStream = jest.fn();
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
replaceTrack: mockReplaceTrack,
addStream: mockAddStream,
on: jest.fn(),
getStats: jest.fn(),
}));
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
// First unmute should use addStream
connection.unmute();
expect(mockAddStream).toHaveBeenCalledWith(expect.any(Object));
expect(wsSend).toHaveBeenCalledWith('unmute');
connection.mute();
// Subsequent unmute should use replaceTrack
connection.unmute();
expect(mockReplaceTrack).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
expect(wsSend).toHaveBeenCalledWith('unmute');
});
it('raise/unraise hand', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: jest.fn(),
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
false,
);
connection.raiseHand();
expect(wsSend).toHaveBeenCalledWith('raise_hand');
connection.unraiseHand();
expect(wsSend).toHaveBeenCalledWith('unraise_hand');
});
it('react', async () => {
const wsSend = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: jest.fn(),
send: wsSend,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
false,
);
const emoji = {name: 'thumbsup', unified: '1F44D'};
connection.sendReaction(emoji);
expect(wsSend).toHaveBeenCalledWith('react', {
data: JSON.stringify(emoji),
});
});
it('ws error', async () => {
const mockCloseCb = jest.fn();
let errorHandler: (err: Error) => void;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'error') {
errorHandler = handler;
}
},
send: jest.fn(),
close: jest.fn(),
}));
await newConnection(
'http://localhost:8065',
'channelID',
mockCloseCb,
() => {},
false,
);
// eslint-disable-next-line
// @ts-ignore
errorHandler(new Error('test error'));
expect(mockCloseCb).not.toHaveBeenCalled();
// eslint-disable-next-line
// @ts-ignore
errorHandler(wsReconnectionTimeoutErr);
expect(mockCloseCb).toHaveBeenCalled();
});
it('ws close', async () => {
let closeHandler: (event: WebSocketCloseEvent) => void;
const mockCloseCb = jest.fn();
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'close') {
closeHandler = handler;
}
},
send: jest.fn(),
}));
await newConnection(
'http://localhost:8065',
'channelID',
mockCloseCb,
() => {},
false,
);
// eslint-disable-next-line
// @ts-ignore
closeHandler({code: 1000, reason: 'normal'});
expect(mockCloseCb).not.toHaveBeenCalled();
});
it('voice track', async () => {
const getUserMedia = require('react-native-webrtc').mediaDevices.getUserMedia;
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(getUserMedia).toHaveBeenCalledWith({
video: false,
audio: true,
});
expect(getUserMedia).toHaveBeenCalledTimes(1);
await connection.initializeVoiceTrack();
expect(getUserMedia).toHaveBeenCalledTimes(1);
});
it('rtc peer', async () => {
const wsSend = jest.fn();
const wsClose = jest.fn();
const peerDestroy = jest.fn();
const peerSignal = jest.fn();
const handlers: Record<string, any> = {};
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: (event: string, handler: any) => {
handlers[event] = handler;
},
getStats: jest.fn(),
destroy: peerDestroy,
signal: peerSignal,
}));
// eslint-disable-next-line
// @ts-ignore
parseRTCStats.mockImplementation(() => mockRTCStats);
let wsMsgHandler;
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
} else if (event === 'message') {
wsMsgHandler = handler;
}
},
send: wsSend,
close: wsClose,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
handlers.candidate({candidate: 'candidate'});
expect(wsSend).toHaveBeenCalledWith('ice', {data: '{"candidate":"candidate"}'});
handlers.offer({offer: 'sdp'});
expect(wsSend).toHaveBeenCalledWith('sdp', {data: zlibSync(strToU8('{"offer":"sdp"}'))}, true);
expect(wsMsgHandler).toBeDefined();
wsMsgHandler!({data: '{"type": "answer"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "answer"}');
wsMsgHandler!({data: '{"type": "offer"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "offer"}');
wsMsgHandler!({data: '{"type": "candidate"}'});
expect(peerSignal).toHaveBeenCalledWith('{"type": "candidate"}');
const mockGetTracks = jest.fn().mockReturnValue([
{
id: 'audioTrackId', enabled: true, stop: jest.fn(), release: jest.fn(),
},
]);
const mockGetVideoTracks = jest.fn().mockReturnValue([]);
handlers.stream({
getTracks: mockGetTracks,
getVideoTracks: mockGetVideoTracks,
});
expect(mockGetTracks).toHaveBeenCalled();
expect(mockGetVideoTracks).toHaveBeenCalled();
handlers.error(new Error('test error'));
expect(wsClose).toHaveBeenCalled();
expect(wsSend).toHaveBeenCalledWith('leave');
expect(peerDestroy).toHaveBeenCalled();
handlers.close();
});
it('rtc peer close', async () => {
const wsSend = jest.fn();
const wsClose = jest.fn();
const peerDestroy = jest.fn();
const peerSignal = jest.fn();
const handlers: Record<string, any> = {};
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => ({
on: (event: string, handler: any) => {
handlers[event] = handler;
},
getStats: jest.fn(),
destroy: peerDestroy,
signal: peerSignal,
}));
// eslint-disable-next-line
// @ts-ignore
parseRTCStats.mockImplementation(() => mockRTCStats);
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: wsSend,
close: wsClose,
}));
const connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
handlers.close();
expect(wsClose).toHaveBeenCalled();
expect(wsSend).toHaveBeenCalledWith('leave');
expect(peerDestroy).toHaveBeenCalled();
});
it('collectICEStats', (done) => {
Platform.OS = 'web';
const wsSend = jest.fn();
const joinHandler = jest.fn(async (event: string, cb: () => void) => {
if (event === 'join') {
@ -127,4 +541,63 @@ describe('newConnection', () => {
expect(joinHandler).toHaveBeenCalled();
});
});
it('waitForPeerConnection', async () => {
let connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
jest.useFakeTimers();
let res = connection.waitForPeerConnection();
jest.runAllTimers();
await expect(res).rejects.toEqual('timed out waiting for peer connection');
// eslint-disable-next-line
// @ts-ignore
RTCPeer.mockImplementation(() => {
return {
getStats: jest.fn(),
on: jest.fn(),
connected: true,
};
});
// eslint-disable-next-line
// @ts-ignore
WebSocketClient.mockImplementation(() => ({
initialize: jest.fn(),
on: (event: string, handler: any) => {
if (event === 'join') {
handler();
}
},
send: jest.fn(),
}));
connection = await newConnection(
'http://localhost:8065',
'channelID',
() => {},
() => {},
true,
);
expect(connection).toBeDefined();
await Promise.resolve();
res = connection.waitForPeerConnection();
jest.runAllTimers();
await expect(res).resolves.not.toThrow();
jest.useRealTimers();
});
});

View file

@ -0,0 +1,273 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// At the moment it feels to much work to make this test pass all the typescript rules due to the heavy mocking involved (e.g., WebSocket global).
// eslint-disable-next-line
// @ts-nocheck
import {encode} from '@msgpack/msgpack/dist';
import DatabaseManager from '@database/manager';
import {WebSocketClient, wsReconnectionTimeoutErr, wsReconnectionTimeout} from './websocket_client';
describe('WebSocketClient', () => {
const mockServerUrl = 'server1';
const mockWsPath = '/api/v4/websocket';
const mockToken = 'mock-token';
beforeAll(async () => {
global.WebSocket = jest.fn();
global.WebSocket.CONNECTING = 0;
global.WebSocket.OPEN = 1;
global.WebSocket.CLOSING = 2;
global.WebSocket.CLOSED = 3;
await DatabaseManager.init(['server1']);
});
afterAll(() => {
delete global.WebSocket;
});
beforeEach(() => {
});
afterEach(() => {
jest.clearAllMocks();
});
it('init', async () => {
let ws = new WebSocketClient('server2', mockWsPath, mockToken);
await ws.initialize();
expect(ws.ws).toBeNull();
ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
expect(ws).toBeDefined();
expect(ws.ws).toBeNull();
await ws.init(false);
expect(ws.ws).not.toBeNull();
expect(ws.ws.onopen).toBeUndefined();
expect(ws.ws.onerror).toBeDefined();
expect(ws.ws.onclose).toBeDefined();
});
it('onerror', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
await ws.init(false);
const err = new Error('test error');
ws.emit = jest.fn();
ws.ws.onerror(err);
expect(ws.emit).toHaveBeenCalledWith('error', err);
});
it('onclose', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
ws.originalConnID = 'originalConnID';
ws.connID = 'connID';
await ws.init(false);
ws.emit = jest.fn();
jest.spyOn(ws, 'init');
jest.spyOn(ws, 'reconnect');
// Test closed case
ws.closed = true;
const ev = {code: 1000, reason: 'test reason'};
ws.ws.onclose(ev);
expect(ws.emit).toHaveBeenCalledWith('close', ev);
expect(ws.reconnect).not.toHaveBeenCalled();
// Test reconnect case
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');
ws.closed = false;
ws.ws.onclose(ev);
expect(ws.emit).toHaveBeenCalledWith('close', ev);
expect(ws.reconnect).toHaveBeenCalled();
expect(setTimeout).toHaveBeenCalledTimes(1);
jest.runAllTimers();
await Promise.resolve();
jest.useRealTimers();
await expect(ws.init).toHaveBeenCalledWith(true);
expect(ws.ws.onopen).toBeDefined();
ws.ws.onopen();
expect(ws.emit).toHaveBeenCalledWith('open', 'originalConnID', 'connID', true);
});
it('state', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
expect(ws.state()).toBe(WebSocket.CLOSED);
await ws.init(false);
ws.ws.readyState = WebSocket.CONNECTING;
expect(ws.state()).toBe(WebSocket.CONNECTING);
});
it('close', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
await ws.init(false);
const close = jest.fn();
ws.ws.close = close;
ws.close();
expect(ws.closed).toBe(true);
expect(close).toHaveBeenCalled();
expect(ws.ws).toBeNull();
expect(ws.seqNo).toBe(1);
expect(ws.serverSeqNo).toBe(0);
expect(ws.connID).toBe('');
expect(ws.originalConnID).toBe('');
});
it('reconnection timeout', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
await ws.init(false);
// Mock error handler
const errorHandler = jest.fn();
ws.on('error', errorHandler);
// Set last disconnect to be longer than timeout
ws.lastDisconnect = Date.now() - (wsReconnectionTimeout + 1000);
ws.reconnect();
expect(errorHandler).toHaveBeenCalledWith(wsReconnectionTimeoutErr);
expect(ws.closed).toBe(true);
});
it('onmessage', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
await ws.init(false);
// Test hello message
const helloMsg = {
event: 'hello',
data: {
connection_id: 'new_conn_id',
},
};
ws.ws.onmessage({data: JSON.stringify(helloMsg)});
expect(ws.connID).toBe('new_conn_id');
expect(ws.serverSeqNo).toBe(0);
// Test signal message
const joinHandler = jest.fn();
ws.on('join', joinHandler);
const joinMsg = {
event: 'custom_com.mattermost.calls_join',
seq: 1,
data: {
connID: 'new_conn_id',
},
};
ws.ws.onmessage({data: JSON.stringify(joinMsg)});
expect(joinHandler).toHaveBeenCalled();
expect(ws.serverSeqNo).toBe(2);
// Test error message
const errorHandler = jest.fn();
ws.on('error', errorHandler);
const errorMsg = {
event: 'custom_com.mattermost.calls_error',
seq: 2,
data: {
connID: 'new_conn_id',
error: 'test error',
},
};
ws.ws.onmessage({data: JSON.stringify(errorMsg)});
expect(errorHandler).toHaveBeenCalledWith(errorMsg.data);
expect(ws.serverSeqNo).toBe(3);
// Test signal message
const messageHandler = jest.fn();
ws.on('message', messageHandler);
const signalMsg = {
event: 'custom_com.mattermost.calls_signal',
seq: 3,
data: {
connID: 'new_conn_id',
signal: 'test signal',
},
};
ws.ws.onmessage({data: JSON.stringify(signalMsg)});
expect(messageHandler).toHaveBeenCalledWith(signalMsg.data);
expect(ws.serverSeqNo).toBe(4);
// Test invalid message
ws.ws.onmessage({data: 'invalid json'});
expect(ws.serverSeqNo).toBe(4);
// Test message without event
ws.ws.onmessage({data: JSON.stringify({seq: 4})});
expect(ws.serverSeqNo).toBe(5);
// Test message with wrong connID
const wrongConnIDMsg = {
event: 'custom_com.mattermost.calls_signal',
seq: 5,
data: {
connID: 'wrong_conn_id',
signal: 'test signal',
},
};
ws.ws.onmessage({data: JSON.stringify(wrongConnIDMsg)});
expect(messageHandler).toHaveBeenCalledTimes(1);
expect(ws.serverSeqNo).toBe(6);
});
it('send', async () => {
const ws = new WebSocketClient(mockServerUrl, mockWsPath, mockToken);
await ws.init(false);
// Mock the WebSocket send method
ws.ws.send = jest.fn();
// Test JSON message
const action = 'test_action';
const data = {foo: 'bar'};
ws.ws.readyState = WebSocket.OPEN;
ws.send(action, data);
expect(ws.ws.send).toHaveBeenCalledWith(JSON.stringify({
action: `custom_com.mattermost.calls_${action}`,
seq: 1,
data,
}));
expect(ws.ws.send).toHaveBeenCalledTimes(1);
// Test binary message
ws.send(action, data, true);
expect(ws.ws.send).toHaveBeenCalledWith(encode({
action: `custom_com.mattermost.calls_${action}`,
seq: 2,
data,
}));
expect(ws.ws.send).toHaveBeenCalledTimes(2);
// Test when WebSocket is not ready
ws.ws.readyState = WebSocket.CONNECTING;
ws.send(action, data);
expect(ws.ws.send).toHaveBeenCalledTimes(2); // No additional call
// Test when WebSocket is closed
ws.ws.readyState = WebSocket.CLOSED;
ws.send(action, data);
expect(ws.ws.send).toHaveBeenCalledTimes(2); // No additional call
});
});

View file

@ -11,7 +11,7 @@ import {getConfigValue} from '@queries/servers/system';
import {logError, logDebug} from '@utils/log';
const wsMinReconnectRetryTimeMs = 1000; // 1 second
const wsReconnectionTimeout = 30000; // 30 seconds
export const wsReconnectionTimeout = 30000; // 30 seconds
const wsReconnectTimeIncrement = 500; // 0.5 seconds
export const wsReconnectionTimeoutErr = new Error('max disconnected time reached');

View file

@ -0,0 +1,581 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import {leaveCall, muteMyself, unraiseHand} from '@calls/actions';
import {createCallAndAddToIds} from '@calls/actions/calls';
import {hostRemovedErr} from '@calls/errors';
import {
callEnded,
callStarted,
getCallsConfig,
getCurrentCall,
receivedCaption,
removeIncomingCall,
setCallForChannel,
setCallScreenOff,
setCallScreenOn,
setCaptioningState,
setChannelEnabled,
setHost,
setRaisedHand,
setRecordingState,
setUserMuted,
setUserVoiceOn,
userJoinedCall,
userLeftCall,
userReacted,
} from '@calls/state';
import {type HostControlsLowerHandMsgData, type HostControlsMsgData, DefaultCallsConfig, DefaultCurrentCall, DefaultCall} from '@calls/types/calls';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import {
handleCallCaption,
handleCallChannelDisabled,
handleCallChannelEnabled,
handleCallEnded,
handleCallHostChanged,
handleCallJobState,
handleCallScreenOff,
handleCallScreenOn,
handleCallStarted,
handleCallState,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserJoined,
handleCallUserLeft,
handleCallUserMuted,
handleCallUserRaiseHand,
handleCallUserReacted,
handleCallUserUnmuted,
handleCallUserUnraiseHand,
handleCallUserVoiceOff,
handleCallUserVoiceOn,
handleHostLowerHand,
handleHostMute,
handleHostRemoved,
handleUserDismissedNotification,
} from './websocket_event_handlers';
import type {
CallHostChangedData,
CallJobStateData,
CallStartData,
CallStateData,
EmptyData,
LiveCaptionData,
UserConnectedData,
UserDisconnectedData,
UserDismissedNotification,
UserJoinedData,
UserLeftData,
UserMutedUnmutedData,
UserRaiseUnraiseHandData,
UserReactionData,
UserScreenOnOffData,
UserVoiceOnOffData,
} from '@mattermost/calls/lib/types';
jest.mock('@actions/remote/user');
jest.mock('@calls/actions/calls');
jest.mock('@calls/state');
jest.mock('@database/manager');
jest.mock('@queries/servers/system');
describe('websocket event handlers', () => {
const serverUrl = 'server1';
const channelId = 'channel-id';
const userId = 'user-id';
const sessionId = 'session-id';
beforeAll(async () => {
await DatabaseManager.init([serverUrl]);
});
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.21.0'},
});
jest.mocked(getCurrentCall).mockReturnValue({
...DefaultCurrentCall,
serverUrl,
channelId,
mySessionId: sessionId,
id: 'call-id',
startTime: Date.now(),
ownerId: 'owner-id',
hostId: 'host-id',
threadId: 'thread-id',
});
});
describe('handleCallUserConnected/Disconnected (deprecated)', () => {
it('should handle user connected for old version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.20.0'},
});
handleCallUserConnected(serverUrl, {
broadcast: {channel_id: channelId},
data: {userID: userId},
} as WebSocketMessage<UserConnectedData>);
expect(fetchUsersByIds).toHaveBeenCalledWith(serverUrl, [userId]);
expect(userJoinedCall).toHaveBeenCalledWith(serverUrl, channelId, userId, userId);
});
it('should not handle user connected for new version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.21.0'},
});
handleCallUserConnected(serverUrl, {
broadcast: {channel_id: channelId},
data: {userID: userId},
} as WebSocketMessage<UserConnectedData>);
expect(fetchUsersByIds).not.toHaveBeenCalled();
expect(userJoinedCall).not.toHaveBeenCalled();
});
it('should handle user disconnected for old version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.20.0'},
});
handleCallUserDisconnected(serverUrl, {
broadcast: {channel_id: channelId},
data: {userID: userId},
} as WebSocketMessage<UserDisconnectedData>);
expect(userLeftCall).toHaveBeenCalledWith(serverUrl, channelId, userId);
});
it('should not handle user disconnected for new version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.21.0'},
});
handleCallUserDisconnected(serverUrl, {
broadcast: {channel_id: channelId},
data: {userID: userId},
} as WebSocketMessage<UserDisconnectedData>);
expect(userLeftCall).not.toHaveBeenCalled();
});
});
describe('handleCallUserJoined/Left', () => {
it('should handle user joined', () => {
handleCallUserJoined(serverUrl, {
broadcast: {channel_id: channelId},
data: {user_id: userId, session_id: sessionId},
} as WebSocketMessage<UserJoinedData>);
expect(fetchUsersByIds).toHaveBeenCalledWith(serverUrl, [userId]);
expect(userJoinedCall).toHaveBeenCalledWith(serverUrl, channelId, userId, sessionId);
});
it('should handle user left', () => {
handleCallUserLeft(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserLeftData>);
expect(userLeftCall).toHaveBeenCalledWith(serverUrl, channelId, sessionId);
});
});
describe('handleCallUserMuted/Unmuted', () => {
it('should handle user muted', () => {
handleCallUserMuted(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserMutedUnmutedData>);
expect(setUserMuted).toHaveBeenCalledWith(serverUrl, channelId, sessionId, true);
});
it('should handle user unmuted', () => {
handleCallUserUnmuted(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserMutedUnmutedData>);
expect(setUserMuted).toHaveBeenCalledWith(serverUrl, channelId, sessionId, false);
});
});
describe('handleCallUserVoiceOn/Off', () => {
it('should handle voice on', () => {
handleCallUserVoiceOn({
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserVoiceOnOffData>);
expect(setUserVoiceOn).toHaveBeenCalledWith(channelId, sessionId, true);
});
it('should handle voice off', () => {
handleCallUserVoiceOff({
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserVoiceOnOffData>);
expect(setUserVoiceOn).toHaveBeenCalledWith(channelId, sessionId, false);
});
});
describe('handleCallStarted/Ended', () => {
it('should handle call started', () => {
const startTime = Date.now();
handleCallStarted(serverUrl, {
broadcast: {channel_id: channelId},
data: {
id: 'call-id',
channelID: channelId,
start_at: startTime,
thread_id: 'thread-id',
owner_id: 'owner-id',
host_id: 'host-id',
},
} as WebSocketMessage<CallStartData>);
expect(callStarted).toHaveBeenCalledWith(serverUrl, {
id: 'call-id',
channelId,
startTime,
threadId: 'thread-id',
screenOn: '',
sessions: {},
ownerId: 'owner-id',
hostId: 'host-id',
dismissed: {},
});
});
it('should handle call ended', () => {
jest.spyOn(DeviceEventEmitter, 'emit');
handleCallEnded(serverUrl, {
broadcast: {channel_id: channelId},
data: {},
} as WebSocketMessage<EmptyData>);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('custom_com.mattermost.calls_call_end', {channelId});
expect(callEnded).toHaveBeenCalledWith(serverUrl, channelId);
});
});
describe('handleCallChannelEnabled/Disabled', () => {
it('should handle channel enabled', () => {
handleCallChannelEnabled(serverUrl, {
broadcast: {channel_id: channelId},
data: {},
} as WebSocketMessage<EmptyData>);
expect(setChannelEnabled).toHaveBeenCalledWith(serverUrl, channelId, true);
});
it('should handle channel disabled', () => {
handleCallChannelDisabled(serverUrl, {
broadcast: {channel_id: channelId},
data: {},
} as WebSocketMessage<EmptyData>);
expect(setChannelEnabled).toHaveBeenCalledWith(serverUrl, channelId, false);
});
});
describe('handleCallScreenOn/Off', () => {
it('should handle screen on', () => {
handleCallScreenOn(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserScreenOnOffData>);
expect(setCallScreenOn).toHaveBeenCalledWith(serverUrl, channelId, sessionId);
});
it('should handle screen off', () => {
handleCallScreenOff(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId},
} as WebSocketMessage<UserScreenOnOffData>);
expect(setCallScreenOff).toHaveBeenCalledWith(serverUrl, channelId, sessionId);
});
});
describe('handleCallUserRaiseHand/UnraiseHand', () => {
it('should handle raise hand', () => {
handleCallUserRaiseHand(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId, raised_hand: 12345},
} as WebSocketMessage<UserRaiseUnraiseHandData>);
expect(setRaisedHand).toHaveBeenCalledWith(serverUrl, channelId, sessionId, 12345);
});
it('should handle unraise hand', () => {
handleCallUserUnraiseHand(serverUrl, {
broadcast: {channel_id: channelId},
data: {session_id: sessionId, raised_hand: 0},
} as WebSocketMessage<UserRaiseUnraiseHandData>);
expect(setRaisedHand).toHaveBeenCalledWith(serverUrl, channelId, sessionId, 0);
});
});
describe('handleCallUserReacted', () => {
it('should handle user reaction for old version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.20.0'},
});
const reactionData = {
user_id: userId,
session_id: sessionId,
reaction: '👍',
timestamp: 12345,
emoji: {name: 'thumbsup', unified: '1F44D'},
};
handleCallUserReacted(serverUrl, {
event: 'custom_com.mattermost.calls_reaction',
seq: 1,
broadcast: {
omit_users: {} as Dictionary<boolean>,
user_id: '',
team_id: '',
channel_id: channelId,
},
data: reactionData,
} as WebSocketMessage<UserReactionData>);
expect(userReacted).toHaveBeenCalledWith(serverUrl, channelId, {
...reactionData,
session_id: userId,
});
});
it('should handle user reaction for new version', () => {
jest.mocked(getCallsConfig).mockReturnValue({
...DefaultCallsConfig,
version: {version: '0.21.0'},
});
const reactionData = {
user_id: userId,
session_id: sessionId,
reaction: '👍',
timestamp: 12345,
emoji: {name: 'thumbsup', unified: '1F44D'},
};
handleCallUserReacted(serverUrl, {
event: 'custom_com.mattermost.calls_reaction',
seq: 1,
broadcast: {
omit_users: {} as Dictionary<boolean>,
user_id: '',
team_id: '',
channel_id: channelId,
},
data: reactionData,
} as WebSocketMessage<UserReactionData>);
expect(userReacted).toHaveBeenCalledWith(serverUrl, channelId, reactionData);
});
});
describe('handleCallHostChanged', () => {
it('should handle host changed', () => {
handleCallHostChanged(serverUrl, {
broadcast: {channel_id: channelId},
data: {hostID: 'new-host-id'},
} as WebSocketMessage<CallHostChangedData>);
expect(setHost).toHaveBeenCalledWith(serverUrl, channelId, 'new-host-id');
});
});
describe('handleHostControls', () => {
it('should handle host mute', () => {
handleHostMute(serverUrl, {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsMsgData>);
expect(muteMyself).toHaveBeenCalled();
});
it('should not handle host mute for different server', () => {
handleHostMute('different-server', {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsMsgData>);
expect(muteMyself).not.toHaveBeenCalled();
});
it('should handle host lower hand', () => {
handleHostLowerHand(serverUrl, {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsLowerHandMsgData>);
expect(unraiseHand).toHaveBeenCalled();
});
it('should not handle host lower hand for different server', () => {
handleHostLowerHand('different-server', {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsLowerHandMsgData>);
expect(unraiseHand).not.toHaveBeenCalled();
});
it('should handle host removed', () => {
handleHostRemoved(serverUrl, {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsMsgData>);
expect(leaveCall).toHaveBeenCalledWith(hostRemovedErr);
});
it('should not handle host removed for different server', () => {
handleHostRemoved('different-server', {
broadcast: {channel_id: channelId},
data: {channel_id: channelId, session_id: sessionId},
} as WebSocketMessage<HostControlsMsgData>);
expect(leaveCall).not.toHaveBeenCalled();
});
});
describe('handleUserDismissedNotification', () => {
it('should handle user dismissed notification for current user', async () => {
const myUserId = 'my-user-id';
jest.mocked(getCurrentUserId).mockResolvedValue(myUserId);
await handleUserDismissedNotification(serverUrl, {
broadcast: {channel_id: channelId},
data: {
userID: myUserId,
callID: 'call-id',
},
} as WebSocketMessage<UserDismissedNotification>);
expect(removeIncomingCall).toHaveBeenCalledWith(serverUrl, 'call-id');
});
it('should not handle user dismissed notification for other users', async () => {
const myUserId = 'my-user-id';
jest.mocked(getCurrentUserId).mockResolvedValue(myUserId);
await handleUserDismissedNotification(serverUrl, {
broadcast: {channel_id: channelId},
data: {
userID: 'other-user-id',
callID: 'call-id',
},
} as WebSocketMessage<UserDismissedNotification>);
expect(removeIncomingCall).not.toHaveBeenCalled();
});
it('should not handle user dismissed notification when database is unavailable', async () => {
await handleUserDismissedNotification('server2', {
broadcast: {channel_id: channelId},
data: {
userID: 'user-id',
callID: 'call-id',
},
} as WebSocketMessage<UserDismissedNotification>);
expect(removeIncomingCall).not.toHaveBeenCalled();
});
});
describe('handleCallJobState', () => {
it('should handle recording job state', () => {
const jobState = {
type: 'recording',
init_at: 12345,
start_at: 12345,
end_at: 0,
};
handleCallJobState(serverUrl, {
broadcast: {channel_id: channelId},
data: {jobState},
} as WebSocketMessage<CallJobStateData>);
expect(setRecordingState).toHaveBeenCalledWith(serverUrl, channelId, jobState);
expect(setCaptioningState).not.toHaveBeenCalled();
});
it('should handle captioning job state', () => {
const jobState = {
type: 'captioning',
init_at: 12345,
start_at: 12345,
end_at: 0,
};
handleCallJobState(serverUrl, {
broadcast: {channel_id: channelId},
data: {jobState},
} as WebSocketMessage<CallJobStateData>);
expect(setCaptioningState).toHaveBeenCalledWith(serverUrl, channelId, jobState);
expect(setRecordingState).not.toHaveBeenCalled();
});
});
describe('handleCallCaption', () => {
it('should handle call caption', () => {
const captionData = {
session_id: 'session-id',
caption_id: 'caption-id',
channel_id: channelId,
user_id: 'user-id',
text: 'Hello world',
start_at: 12345,
end_at: 12346,
};
handleCallCaption(serverUrl, {
event: 'custom_com.mattermost.calls_caption',
seq: 1,
broadcast: {
omit_users: {} as Dictionary<boolean>,
user_id: '',
team_id: '',
channel_id: channelId,
},
data: captionData,
} as WebSocketMessage<LiveCaptionData>);
expect(receivedCaption).toHaveBeenCalledWith(serverUrl, captionData);
});
});
describe('handleCallState', () => {
it('should handle call state', () => {
const callState = {
...DefaultCall,
id: 'call-id',
channelId,
startTime: Date.now(),
screenOn: '',
sessions: {},
ownerId: 'owner-id',
hostId: 'host-id',
dismissed: {},
recording: {
init_at: 12345,
start_at: 12345,
end_at: 0,
},
live_captions: {
init_at: 12345,
start_at: 12345,
end_at: 0,
},
};
jest.mocked(createCallAndAddToIds).mockReturnValue(callState);
handleCallState(serverUrl, {
broadcast: {channel_id: channelId},
data: {
channel_id: channelId,
call: JSON.stringify(callState),
},
} as WebSocketMessage<CallStateData>);
expect(createCallAndAddToIds).toHaveBeenCalledWith(channelId, callState);
expect(setCallForChannel).toHaveBeenCalledWith(serverUrl, channelId, callState);
expect(setRecordingState).toHaveBeenCalledWith(serverUrl, channelId, callState.recording);
expect(setCaptioningState).toHaveBeenCalledWith(serverUrl, channelId, callState.live_captions);
});
});
});

View file

@ -0,0 +1,275 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, renderHook} from '@testing-library/react-hooks';
import {Alert, AppState} from 'react-native';
import Permissions from 'react-native-permissions';
import {initializeVoiceTrack} from '@calls/actions/calls';
import {
getCurrentCall,
getCallsConfig,
setMicPermissionsGranted,
useCallsState,
useChannelsWithCalls,
useCurrentCall,
useGlobalCallsState,
useIncomingCalls,
} from '@calls/state';
import {Screens} from '@constants';
import {
CURRENT_CALL_BAR_HEIGHT,
JOIN_CALL_BAR_HEIGHT,
} from '@constants/view';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {queryAllActiveServers} from '@queries/app/servers';
import {getCurrentUser} from '@queries/servers/user';
import {openAsBottomSheet} from '@screens/navigation';
import {useTryCallsFunction, usePermissionsChecker, useCallsAdjustment, useHostControlsAvailable, useHostMenus} from './hooks';
jest.mock('react-intl', () => ({
useIntl: jest.fn().mockReturnValue({
formatMessage: jest.fn(({defaultMessage}) => defaultMessage),
}),
defineMessage: (message: any) => message,
defineMessages: (messages: any) => messages,
}));
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => 'server1'),
}));
jest.mock('@context/theme', () => ({
useTheme: jest.fn(() => ({})),
}));
jest.mock('@calls/actions/calls', () => ({
initializeVoiceTrack: jest.fn(),
}));
jest.mock('@calls/state', () => ({
getCurrentCall: jest.fn(),
getCallsConfig: jest.fn(),
setMicPermissionsGranted: jest.fn(),
useCallsState: jest.fn(),
useChannelsWithCalls: jest.fn(),
useCurrentCall: jest.fn(),
useGlobalCallsState: jest.fn(),
useIncomingCalls: jest.fn(),
}));
jest.mock('@managers/network_manager', () => ({
getClient: jest.fn(),
}));
jest.mock('@queries/app/servers', () => ({
queryAllActiveServers: jest.fn(),
}));
jest.mock('@queries/servers/user', () => ({
getCurrentUser: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAsBottomSheet: jest.fn(),
}));
describe('Calls Hooks', () => {
beforeAll(async () => {
await DatabaseManager.init(['server1']);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('useTryCallsFunction', () => {
const mockFn = jest.fn();
const mockClient = {
getEnabled: jest.fn(),
};
beforeEach(() => {
(NetworkManager.getClient as jest.Mock).mockReturnValue(mockClient);
});
it('executes function when calls enabled', async () => {
mockClient.getEnabled.mockResolvedValue(true);
const {result} = renderHook(() => useTryCallsFunction(mockFn));
const [tryFn] = result.current;
await act(async () => {
await tryFn();
});
expect(mockFn).toHaveBeenCalled();
});
it('shows alert when calls not enabled', async () => {
mockClient.getEnabled.mockResolvedValue(false);
const mockAlert = jest.spyOn(Alert, 'alert');
const {result} = renderHook(() => useTryCallsFunction(mockFn));
const [tryFn] = result.current;
await act(async () => {
await tryFn();
});
expect(mockFn).not.toHaveBeenCalled();
expect(mockAlert).toHaveBeenCalled();
});
});
describe('usePermissionsChecker', () => {
beforeEach(() => {
jest.spyOn(AppState, 'addEventListener');
});
it('checks permissions when not granted', async () => {
const mockCheck = jest.spyOn(Permissions, 'check').mockResolvedValue(Permissions.RESULTS.GRANTED);
const {result} = renderHook(() => usePermissionsChecker(false));
expect(result.current).toBe(false);
// Simulate app becoming active
await act(async () => {
AppState.currentState = 'active';
const callback = (AppState.addEventListener as jest.Mock).mock.calls[0][1];
await callback('active');
});
expect(mockCheck).toHaveBeenCalled();
expect(initializeVoiceTrack).toHaveBeenCalled();
expect(setMicPermissionsGranted).toHaveBeenCalledWith(true);
expect(result.current).toBe(true);
});
it('skips check when already granted', () => {
const mockCheck = jest.spyOn(Permissions, 'check');
renderHook(() => usePermissionsChecker(true));
expect(mockCheck).not.toHaveBeenCalled();
});
});
describe('useCallsAdjustment', () => {
beforeEach(() => {
(useIncomingCalls as jest.Mock).mockReturnValue({incomingCalls: []});
(useChannelsWithCalls as jest.Mock).mockReturnValue({});
(useCallsState as jest.Mock).mockReturnValue({calls: {}, myUserId: 'user1'});
(useGlobalCallsState as jest.Mock).mockReturnValue({micPermissionsGranted: true});
(useCurrentCall as jest.Mock).mockReturnValue(null);
(queryAllActiveServers as jest.Mock).mockReturnValue({
fetch: () => Promise.resolve([{id: 'server1'}]),
});
});
it('calculates adjustment with no calls', () => {
const {result} = renderHook(() => useCallsAdjustment('server1', 'channel1'));
expect(result.current).toBe(0);
});
it('includes current call bar height', () => {
(useCurrentCall as jest.Mock).mockReturnValue({id: 'call1', channelId: 'channel1'});
const {result} = renderHook(() => useCallsAdjustment('server1', 'channel1'));
expect(result.current).toBe(CURRENT_CALL_BAR_HEIGHT + 8);
});
it('includes join call banner height', () => {
(useChannelsWithCalls as jest.Mock).mockReturnValue({channel1: true});
const {result} = renderHook(() => useCallsAdjustment('server1', 'channel1'));
expect(result.current).toBe(JOIN_CALL_BAR_HEIGHT + 8);
});
});
describe('useHostControlsAvailable', () => {
beforeEach(() => {
(getCallsConfig as jest.Mock).mockReturnValue({
HostControlsAllowed: true,
});
(getCurrentUser as jest.Mock).mockResolvedValue({
roles: 'system_user',
});
});
it('returns true for host', async () => {
(getCurrentCall as jest.Mock).mockReturnValueOnce({
serverUrl: 'server1',
myUserId: 'user1',
hostId: 'user1',
});
const {result} = renderHook(() => useHostControlsAvailable());
expect(result.current).toBe(true);
});
it('returns true for admin', async () => {
(getCurrentCall as jest.Mock).mockReturnValueOnce({
serverUrl: 'server1',
myUserId: 'user1',
hostId: 'host1',
});
(getCurrentUser as jest.Mock).mockResolvedValueOnce({
roles: 'system_admin',
});
const {result, waitForNextUpdate} = renderHook(() => useHostControlsAvailable());
await waitForNextUpdate();
expect(result.current).toBe(true);
});
});
describe('useHostMenus', () => {
const mockSession = {
userId: 'user1',
sessionId: 'session1',
muted: false,
raisedHand: 0,
};
beforeEach(() => {
(useCurrentCall as jest.Mock).mockReturnValue({
myUserId: 'user1',
hostId: 'host1',
});
(getCallsConfig as jest.Mock).mockReturnValue({
HostControlsAllowed: true,
});
});
it('opens host controls when admin', async () => {
(getCurrentUser as jest.Mock).mockResolvedValueOnce({
roles: 'system_admin',
});
const {result} = renderHook(() => useHostMenus());
await act(async () => {
await result.current.onPress(mockSession)();
});
expect(openAsBottomSheet).toHaveBeenCalledWith(expect.objectContaining({
screen: Screens.CALL_HOST_CONTROLS,
}));
});
it('opens host controls when host and clicking another profile', async () => {
(useCurrentCall as jest.Mock).mockReturnValue({
myUserId: 'user1',
hostId: 'user1',
});
const {result} = renderHook(() => useHostMenus());
await act(async () => {
await result.current.onPress(mockSession)();
});
expect(openAsBottomSheet).toHaveBeenCalledWith(expect.objectContaining({
screen: Screens.USER_PROFILE,
}));
});
});
});

View file

@ -0,0 +1,282 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {of as of$, firstValueFrom} from 'rxjs';
import {
observeCallsConfig,
observeCallsState,
observeChannelsWithCalls,
observeCurrentCall,
observeIncomingCalls,
} from '@calls/state';
import {DefaultCallsState} from '@calls/types/calls';
import {License} from '@constants';
import DatabaseManager from '@database/manager';
import {observeConfigValue, observeLicense} from '@queries/servers/system';
import {queryUsersById} from '@queries/servers/user';
import {
observeIsCallsEnabledInChannel,
observeIsCallLimitRestricted,
observeCallStateInChannel,
observeEndCallDetails,
observeCurrentSessionsDict,
} from './index';
jest.mock('@calls/state');
jest.mock('@queries/servers/system');
jest.mock('@queries/servers/user');
describe('Calls Observers', () => {
const serverUrl = 'https://server.com';
const channelId = 'channel1';
const database = {} as any;
beforeEach(() => {
jest.clearAllMocks();
// Mock DatabaseManager
(DatabaseManager as any).serverDatabases = {
'test-server': {
database: {},
},
};
});
describe('observeIsCallsEnabledInChannel', () => {
beforeEach(() => {
(observeCallsConfig as jest.Mock).mockReturnValue(of$({
pluginEnabled: true,
DefaultEnabled: true,
}));
(observeCallsState as jest.Mock).mockReturnValue(of$(DefaultCallsState));
(observeConfigValue as jest.Mock).mockReturnValue(of$('7.6.0'));
});
it('should return false when plugin is disabled regardless of other settings', async () => {
(observeCallsConfig as jest.Mock).mockReturnValue(of$({
pluginEnabled: false,
DefaultEnabled: true,
}));
(observeCallsState as jest.Mock).mockReturnValue(of$(DefaultCallsState));
(observeConfigValue as jest.Mock).mockReturnValue(of$('10.5.0'));
const result = await firstValueFrom(observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)));
expect(result).toBe(false);
});
it('should return true when explicitly enabled', async () => {
(observeCallsState as jest.Mock).mockReturnValue(of$({
enabled: {[channelId]: true},
}));
const result = await firstValueFrom(observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)));
expect(result).toBe(true);
});
it('should return false when explicitly disabled', async () => {
(observeCallsState as jest.Mock).mockReturnValue(of$({
enabled: {[channelId]: false},
}));
const result = await firstValueFrom(observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)));
expect(result).toBe(false);
});
it('should return true for GA server when not explicitly disabled', async () => {
(observeCallsState as jest.Mock).mockReturnValue(of$({enabled: {}}));
(observeConfigValue as jest.Mock).mockReturnValue(of$('7.6.0'));
const result = await firstValueFrom(observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)));
expect(result).toBe(true);
});
it('should use default enabled when not GA server and not explicitly set', async () => {
(observeCallsState as jest.Mock).mockReturnValue(of$({enabled: {}}));
(observeConfigValue as jest.Mock).mockReturnValue(of$('7.5.0'));
(observeCallsConfig as jest.Mock).mockReturnValue(of$({
pluginEnabled: true,
DefaultEnabled: true,
}));
const result = await firstValueFrom(observeIsCallsEnabledInChannel(database, serverUrl, of$(channelId)));
expect(result).toBe(true);
});
});
describe('observeIsCallLimitRestricted', () => {
it('should detect when call limit is not restricted', async () => {
(observeCallsConfig as jest.Mock).mockReturnValue(of$({
MaxCallParticipants: 8,
sku_short_name: License.SKU_SHORT_NAME.Professional,
}));
(observeCallsState as jest.Mock).mockReturnValue(of$({
calls: {
[channelId]: {
sessions: {user1: {}, user2: {}},
},
},
}));
(observeLicense as jest.Mock).mockReturnValue(of$({Cloud: 'false'}));
const result = await firstValueFrom(observeIsCallLimitRestricted(database, serverUrl, channelId));
expect(result).toEqual({
limitRestricted: false,
maxParticipants: 8,
isCloudStarter: false,
});
});
it('should detect when call limit is restricted', async () => {
(observeCallsConfig as jest.Mock).mockReturnValue(of$({
MaxCallParticipants: 3,
sku_short_name: License.SKU_SHORT_NAME.Starter,
}));
(observeCallsState as jest.Mock).mockReturnValue(of$({
calls: {
[channelId]: {
sessions: {user1: {}, user2: {}, user3: {}},
},
},
}));
(observeLicense as jest.Mock).mockReturnValue(of$({Cloud: 'true'}));
const result = await firstValueFrom(observeIsCallLimitRestricted(database, serverUrl, channelId));
expect(result).toEqual({
limitRestricted: true,
maxParticipants: 3,
isCloudStarter: true,
});
});
});
describe('observeCallStateInChannel', () => {
it('should not show banner when no call in channel', async () => {
(observeChannelsWithCalls as jest.Mock).mockReturnValue(of$({}));
(observeCurrentCall as jest.Mock).mockReturnValue(of$(null));
(observeCallsState as jest.Mock).mockReturnValue(of$({
calls: {},
myUserId: 'user1',
}));
(observeIncomingCalls as jest.Mock).mockReturnValue(of$({
incomingCalls: [],
}));
const {showJoinCallBanner, isInACall, showIncomingCalls} = observeCallStateInChannel(serverUrl, database, of$(channelId));
const bannerVisible = await firstValueFrom(showJoinCallBanner);
const inCall = await firstValueFrom(isInACall);
const hasIncoming = await firstValueFrom(showIncomingCalls);
expect(bannerVisible).toBe(false);
expect(inCall).toBe(false);
expect(hasIncoming).toBe(false);
});
it('should detect active call in channel', async () => {
(observeChannelsWithCalls as jest.Mock).mockReturnValue(of$({
[channelId]: true,
}));
(observeCurrentCall as jest.Mock).mockReturnValue(of$({
channelId: 'different-channel',
connected: true,
}));
(observeCallsState as jest.Mock).mockReturnValue(of$({
calls: {
[channelId]: {
dismissed: {},
},
},
myUserId: 'user1',
}));
(observeIncomingCalls as jest.Mock).mockReturnValue(of$({
incomingCalls: [],
}));
const {showJoinCallBanner, isInACall, showIncomingCalls} = observeCallStateInChannel(serverUrl, database, of$(channelId));
const bannerVisible = await firstValueFrom(showJoinCallBanner);
const inCall = await firstValueFrom(isInACall);
const hasIncoming = await firstValueFrom(showIncomingCalls);
expect(bannerVisible).toBe(true);
expect(inCall).toBe(true);
expect(hasIncoming).toBe(false);
});
});
describe('observeCurrentSessionsDict', () => {
it('should handle empty/null call state', async () => {
(observeCurrentCall as jest.Mock).mockReturnValue(of$(null));
const result = await firstValueFrom(observeCurrentSessionsDict());
expect(result).toEqual({});
});
it('should fill user models for sessions', async () => {
const sessions = {
session1: {userId: 'user1'},
session2: {userId: 'user2'},
};
(observeCurrentCall as jest.Mock).mockReturnValue(of$({
sessions,
serverUrl: 'test-server',
}));
const userModels = [
{id: 'user1', username: 'user.one'},
{id: 'user2', username: 'user.two'},
];
(queryUsersById as jest.Mock).mockReturnValue({
observeWithColumns: () => of$(userModels),
});
const result = await firstValueFrom(observeCurrentSessionsDict());
expect(result).toEqual({
session1: {...sessions.session1, userModel: userModels[0]},
session2: {...sessions.session2, userModel: userModels[1]},
});
});
});
describe('observeEndCallDetails', () => {
it('should handle empty call state', async () => {
(observeCurrentCall as jest.Mock).mockReturnValue(of$(null));
const {otherParticipants, isAdmin, isHost} = observeEndCallDetails();
const hasOthers = await firstValueFrom(otherParticipants);
const admin = await firstValueFrom(isAdmin);
const host = await firstValueFrom(isHost);
expect(hasOthers).toBe(false);
expect(admin).toBe(false);
expect(host).toBe(false);
});
it('should determine host and admin status', async () => {
(observeCurrentCall as jest.Mock).mockReturnValue(of$({
myUserId: 'user1',
mySessionId: 'session1',
hostId: 'user1',
sessions: {
session1: {
userModel: {
roles: 'system_admin',
},
},
session2: {},
},
}));
const {otherParticipants, isAdmin, isHost} = observeEndCallDetails();
const hasOthers = await firstValueFrom(otherParticipants);
const admin = await firstValueFrom(isAdmin);
const host = await firstValueFrom(isHost);
expect(hasOthers).toBe(true);
expect(admin).toBe(true);
expect(host).toBe(true);
});
});
});

View file

@ -158,7 +158,7 @@ export const observeEndCallDetails = () => {
distinctUntilChanged(),
);
const isHost = cc.pipe(
switchMap((call) => of$(call?.hostId === call?.myUserId)),
switchMap((call) => of$(call ? call.hostId === call.myUserId : false)),
distinctUntilChanged(),
);

View file

@ -4,6 +4,7 @@
import assert from 'assert';
import {act, renderHook} from '@testing-library/react-hooks'; // Use instead of react-native version due to different behavior. Consider migrating
import {AppState} from 'react-native';
import {needsRecordingAlert} from '@calls/alerts';
import {
@ -19,6 +20,7 @@ import {
setChannelsWithCalls,
setCurrentCall,
setHost,
setIncomingCalls,
setJoiningChannelId,
setMicPermissionsErrorDismissed,
setMicPermissionsGranted,
@ -30,6 +32,7 @@ import {
useGlobalCallsState,
useIncomingCalls,
userReacted,
setCallForChannel,
} from '@calls/state';
import {
callEnded,
@ -48,6 +51,8 @@ import {
setUserVoiceOn,
userJoinedCall,
userLeftCall,
callsOnAppStateChange,
playIncomingCallsRinging,
} from '@calls/state/actions';
import {
AudioDevice,
@ -95,6 +100,7 @@ jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(() => Promise.resolve({
username: 'user-5',
})),
getCurrentUser: jest.fn(),
}));
jest.mock('react-native-navigation', () => ({
@ -1440,6 +1446,177 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[2], expectedIncomingCalls);
});
it('playIncomingCallsRinging', async () => {
const initialIncomingCalls = {
...DefaultIncomingCalls,
incomingCalls: [{
callID: 'callDM',
callerID: 'user-5',
callerModel: {username: 'user-5'} as UserModel,
channelID: 'channel-private',
myUserId: 'myId',
serverUrl: 'server1',
startAt: 123,
type: 0,
}],
currentRingingCallId: undefined,
};
// setup
await DatabaseManager.init(['server1']);
const {result} = renderHook(() => useIncomingCalls());
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
});
assert.deepEqual(result.current, initialIncomingCalls);
AppState.currentState = 'active';
const getCurrentUser = require('@queries/servers/user').getCurrentUser;
getCurrentUser.mockResolvedValueOnce({
id: 'user-5',
roles: 'user',
notifyProps: {
calls_mobile_sound: 'true',
calls_mobile_notification_sound: 'Calm',
},
});
// test: should not ring when in DND
await act(async () => {
await playIncomingCallsRinging('server1', 'call1', 'dnd');
});
assert.deepEqual(result.current, initialIncomingCalls);
// test: should not ring when in OOO
await act(async () => {
await playIncomingCallsRinging('server1', 'call1', 'ooo');
});
assert.deepEqual(result.current, initialIncomingCalls);
// test: should ring when online
await act(async () => {
await playIncomingCallsRinging('server1', 'call1', 'online');
});
assert.deepEqual(result.current, {
...initialIncomingCalls,
currentRingingCallId: 'call1',
callIdHasRung: {call1: true},
});
// test: should not ring for same call again
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
await playIncomingCallsRinging('server1', 'call1', 'online');
});
assert.deepEqual(result.current, initialIncomingCalls);
// test: should not ring when already ringing
await act(async () => {
await setIncomingCalls({
...initialIncomingCalls,
currentRingingCallId: 'call2',
});
await playIncomingCallsRinging('server1', 'call3', 'online');
});
assert.deepEqual(result.current.currentRingingCallId, 'call2');
});
it('callsOnAppStateChange', async () => {
const initialIncomingCalls = {
...DefaultIncomingCalls,
currentRingingCallId: 'call1',
};
// setup
const {result} = renderHook(() => useIncomingCalls());
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
});
assert.deepEqual(result.current, initialIncomingCalls);
// test going to background
await act(async () => callsOnAppStateChange('background'));
assert.deepEqual(result.current, {...initialIncomingCalls, currentRingingCallId: undefined});
// test going to inactive
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
await callsOnAppStateChange('inactive');
});
assert.deepEqual(result.current, {...initialIncomingCalls, currentRingingCallId: undefined});
// test going to active (should not change state)
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
await callsOnAppStateChange('active');
});
assert.deepEqual(result.current, initialIncomingCalls);
// test previous state
await act(async () => {
await setIncomingCalls(initialIncomingCalls);
await callsOnAppStateChange('active');
});
assert.deepEqual(result.current, initialIncomingCalls);
});
it('setCallForChannel', async () => {
const initialCallsState = {
...DefaultCallsState,
calls: {'channel-1': call1},
enabled: {'channel-1': true},
};
const initialChannelsWithCalls = {'channel-1': true};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
});
await act(async () => {
await setCallsState('server1', initialCallsState);
await setChannelsWithCalls('server1', initialChannelsWithCalls);
await setCurrentCall(initialCurrentCallState);
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], initialChannelsWithCalls);
assert.deepEqual(result.current[2], initialCurrentCallState);
// test: add new call
await act(async () => setCallForChannel('server1', 'channel-2', call2, true));
assert.deepEqual(result.current[0], {
...initialCallsState,
calls: {...initialCallsState.calls, 'channel-2': call2},
enabled: {...initialCallsState.enabled, 'channel-2': true},
});
assert.deepEqual(result.current[1], {...initialChannelsWithCalls, 'channel-2': true});
// test: update existing call
const updatedCall1 = {...call1, screenOn: 'newScreen'};
await act(async () => setCallForChannel('server1', 'channel-1', updatedCall1));
assert.deepEqual((result.current[0] as CallsState).calls['channel-1'], updatedCall1);
assert.deepEqual(result.current[2], {...initialCurrentCallState, screenOn: 'newScreen'});
// test: remove call
await act(async () => setCallForChannel('server1', 'channel-1', undefined));
assert.deepEqual(result.current[0], {
...initialCallsState,
calls: {'channel-2': call2},
enabled: {'channel-1': true, 'channel-2': true},
});
assert.deepEqual(result.current[1], {'channel-2': true});
// test: just update enabled state
await act(async () => setCallForChannel('server1', 'channel-3', undefined, true));
assert.deepEqual((result.current[0] as CallsState).enabled['channel-3'], true);
assert.deepEqual(result.current[1], {'channel-2': true});
});
it('captions', () => {
const initialCallsState = {
...DefaultCallsState,

View file

@ -3,10 +3,33 @@
import assert from 'assert';
import {type CallsConfigState, DefaultCallsConfig} from '@calls/types/calls';
import {License} from '@constants';
import {Alert} from 'react-native';
import {SelectedTrackType} from 'react-native-video';
import {getICEServersConfigs, getCallPropsFromPost} from './utils';
import {type CallsConfigState, DefaultCallsConfig} from '@calls/types/calls';
import {License, Post} from '@constants';
import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification';
import {
getICEServersConfigs,
getCallPropsFromPost,
sortSessions,
getHandsRaised,
getHandsRaisedNames,
isSupportedServerCalls,
isHostControlsAllowed,
areGroupCallsAllowed,
isCallsCustomMessage,
idsAreEqual,
errorAlert,
makeCallsTheme,
isCallsStartedMessage,
hasCaptions,
getTranscriptionUri,
} from './utils';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
describe('getICEServersConfigs', () => {
it('backwards compatible case, no ICEServersConfigs present', () => {
@ -98,6 +121,298 @@ describe('getICEServersConfigs', () => {
});
});
describe('sortSessions', () => {
const locale = 'en';
const teammateNameDisplay = 'username';
it('returns empty array for undefined sessions', () => {
expect(sortSessions(locale, teammateNameDisplay, undefined)).toEqual([]);
});
it('sorts by name', () => {
const sessions = {
1: {
sessionId: '1',
userId: 'user1',
muted: true,
raisedHand: 0,
userModel: {username: 'charlie'} as UserModel,
},
2: {
sessionId: '2',
userId: 'user2',
muted: true,
raisedHand: 0,
userModel: {username: 'alice'} as UserModel,
},
3: {
sessionId: '3',
userId: 'user3',
muted: true,
raisedHand: 0,
userModel: {username: 'bob'} as UserModel,
},
};
const sorted = sortSessions(locale, teammateNameDisplay, sessions);
expect(sorted.map((s) => s.userModel?.username)).toEqual(['alice', 'bob', 'charlie']);
});
it('sorts by state (presenter > raised hand > unmuted)', () => {
const sessions = {
1: {
sessionId: '1',
userId: 'user1',
muted: true,
raisedHand: 0,
userModel: {username: 'a'} as UserModel,
},
2: {
sessionId: '2',
userId: 'user2',
muted: false,
raisedHand: 0,
userModel: {username: 'b'} as UserModel,
},
3: {
sessionId: '3',
userId: 'user3',
muted: true,
raisedHand: 1000,
userModel: {username: 'c'} as UserModel,
},
};
const sorted = sortSessions(locale, teammateNameDisplay, sessions, '2');
expect(sorted.map((s) => s.userModel?.username)).toEqual(['b', 'c', 'a']);
});
it('sorts by raised hand timestamp when multiple hands are raised', () => {
const sessions = {
1: {
sessionId: '1',
userId: 'user1',
muted: true,
raisedHand: 2000, // Raised hand second
userModel: {username: 'a'} as UserModel,
},
2: {
sessionId: '2',
userId: 'user2',
muted: true,
raisedHand: 1000, // Raised hand first
userModel: {username: 'b'} as UserModel,
},
};
const sorted = sortSessions(locale, teammateNameDisplay, sessions);
expect(sorted.map((s) => s.userModel?.username)).toEqual(['b', 'a']);
});
});
describe('getHandsRaised', () => {
it('returns sessions with raised hands', () => {
const sessions = {
1: {
sessionId: '1',
userId: 'user1',
muted: true,
raisedHand: 0,
},
2: {
sessionId: '2',
userId: 'user2',
muted: true,
raisedHand: 1000,
},
3: {
sessionId: '3',
userId: 'user3',
muted: true,
raisedHand: 2000,
},
};
const raised = getHandsRaised(sessions);
expect(raised.length).toBe(2);
expect(raised.map((s) => s.sessionId).sort()).toEqual(['2', '3']);
});
});
describe('getHandsRaisedNames', () => {
const locale = 'en';
const teammateNameDisplay = 'username';
const intl = {
formatMessage: ({id}: {id: string}) => (id === 'mobile.calls_you_2' ? 'You' : id),
} as IntlShape;
it('returns names in order of raised hand time', () => {
const sessions = [
{
sessionId: '1',
userId: 'user1',
raisedHand: 2000,
userModel: {username: 'alice'} as UserModel,
muted: false,
},
{
sessionId: '2',
userId: 'user2',
raisedHand: 1000,
userModel: {username: 'bob'} as UserModel,
muted: false,
},
];
const names = getHandsRaisedNames(sessions, '3', locale, teammateNameDisplay, intl);
expect(names).toEqual(['bob', 'alice']);
});
it('shows "You" for current user', () => {
const sessions = [
{
sessionId: '1',
userId: 'user1',
raisedHand: 1000,
userModel: {username: 'alice'} as UserModel,
muted: false,
},
];
const names = getHandsRaisedNames(sessions, '1', locale, teammateNameDisplay, intl);
expect(names).toEqual(['You']);
});
});
describe('isSupportedServerCalls', () => {
it('returns false for undefined version', () => {
expect(isSupportedServerCalls(undefined)).toBe(false);
});
it('returns true for supported version', () => {
expect(isSupportedServerCalls('7.6.0')).toBe(true);
});
it('returns false for unsupported version', () => {
expect(isSupportedServerCalls('6.2.0')).toBe(false);
});
});
describe('isHostControlsAllowed and areGroupCallsAllowed', () => {
const config: CallsConfigState = {
...DefaultCallsConfig,
HostControlsAllowed: true,
GroupCallsAllowed: true,
};
it('returns config values', () => {
expect(isHostControlsAllowed(config)).toBe(true);
expect(areGroupCallsAllowed(config)).toBe(true);
});
it('returns false for undefined values', () => {
expect(isHostControlsAllowed({} as CallsConfigState)).toBe(false);
expect(areGroupCallsAllowed({} as CallsConfigState)).toBe(false);
});
});
describe('isCallsCustomMessage', () => {
it('identifies calls messages', () => {
expect(isCallsCustomMessage({type: Post.POST_TYPES.CUSTOM_CALLS} as Post)).toBe(true);
expect(isCallsCustomMessage({type: 'regular_post' as unknown} as Post)).toBe(false);
expect(isCallsCustomMessage({} as Post)).toBe(false);
});
});
describe('idsAreEqual', () => {
it('compares arrays of ids', () => {
expect(idsAreEqual(['1', '2', '3'], ['1', '2', '3'])).toBe(true);
expect(idsAreEqual(['1', '2', '3'], ['3', '2', '1'])).toBe(true);
expect(idsAreEqual(['1', '2'], ['1', '2', '3'])).toBe(false);
expect(idsAreEqual(['1', '2', '3'], ['1', '2'])).toBe(false);
expect(idsAreEqual(['1', '2', '3'], ['4', '5', '6'])).toBe(false);
});
});
describe('errorAlert', () => {
it('shows error alert', () => {
const mockAlert = jest.spyOn(Alert, 'alert');
const intl = {
formatMessage: ({defaultMessage}: {defaultMessage: string}, values?: any) => {
if (values) {
return defaultMessage.replace('{error}', values.error);
}
return defaultMessage;
},
} as IntlShape;
errorAlert('test error', intl);
expect(mockAlert).toHaveBeenCalledWith(
'Error',
'Error: test error',
);
});
});
describe('makeCallsTheme', () => {
it('creates calls theme from base theme', () => {
const theme = {
sidebarBg: '#000000',
} as Theme;
const callsTheme = makeCallsTheme(theme);
expect(callsTheme.callsBg).toBeDefined();
expect(callsTheme.callsBgRgb).toBeDefined();
expect(callsTheme.callsBadgeBg).toBeDefined();
});
});
describe('isCallsStartedMessage', () => {
it('identifies calls notifications', () => {
expect(isCallsStartedMessage({sub_type: NOTIFICATION_SUB_TYPE.CALLS} as NotificationData)).toBe(true);
expect(isCallsStartedMessage({message: "You've been invited to a call"} as NotificationData)).toBe(true);
expect(isCallsStartedMessage({message: '\u200bUser is inviting you to a call'} as NotificationData)).toBe(true);
expect(isCallsStartedMessage({message: 'regular message'} as NotificationData)).toBe(false);
});
});
describe('hasCaptions', () => {
it('checks for valid captions', () => {
expect(hasCaptions({captions: [{title: 'test', language: 'en', file_id: '123'}]})).toBe(true);
expect(hasCaptions({captions: []})).toBe(false);
expect(hasCaptions({})).toBe(false);
expect(hasCaptions(undefined)).toBe(false);
});
});
describe('getTranscriptionUri', () => {
const serverUrl = 'https://example.com';
it('returns empty track when no captions', () => {
const result = getTranscriptionUri(serverUrl, {});
expect(result.tracks).toBeUndefined();
expect(result.selected).toEqual({type: SelectedTrackType.DISABLED, value: ''});
});
it('returns track info when captions exist', () => {
const props = {
captions: [{
title: 'English',
language: 'en',
file_id: '123',
}],
};
const result = getTranscriptionUri(serverUrl, props);
expect(result.tracks).toBeDefined();
expect(result.tracks?.length).toBe(1);
expect(result.selected).toEqual({type: SelectedTrackType.INDEX, value: 0});
});
});
describe('getCallPropsFromPost', () => {
test('undefined props', () => {
const post = {} as Post;