From 44bce6c319c75a4dd7086a8dcd241fd4eedfc472 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Tue, 21 Jan 2025 12:02:03 -0600 Subject: [PATCH] [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 --- app/products/calls/actions/calls.test.ts | 851 +++++++++++++++++- app/products/calls/actions/calls.ts | 12 +- app/products/calls/actions/index.ts | 15 + .../calls/actions/permissions.test.ts | 93 ++ app/products/calls/alerts.test.ts | 503 +++++++++++ app/products/calls/calls_manager.test.ts | 51 ++ app/products/calls/client/rest.test.ts | 221 +++++ app/products/calls/client/rest.ts | 2 +- .../calls/connection/connection.test.ts | 479 +++++++++- .../calls/connection/websocket_client.test.ts | 273 ++++++ .../calls/connection/websocket_client.ts | 2 +- .../websocket_event_handlers.test.ts | 581 ++++++++++++ app/products/calls/hooks.test.ts | 275 ++++++ app/products/calls/observers/index.test.ts | 282 ++++++ app/products/calls/observers/index.ts | 2 +- app/products/calls/state/actions.test.ts | 177 ++++ app/products/calls/utils.test.ts | 321 ++++++- 17 files changed, 4095 insertions(+), 45 deletions(-) create mode 100644 app/products/calls/actions/permissions.test.ts create mode 100644 app/products/calls/alerts.test.ts create mode 100644 app/products/calls/calls_manager.test.ts create mode 100644 app/products/calls/client/rest.test.ts create mode 100644 app/products/calls/connection/websocket_client.test.ts create mode 100644 app/products/calls/connection/websocket_event_handlers.test.ts create mode 100644 app/products/calls/hooks.test.ts create mode 100644 app/products/calls/observers/index.test.ts diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 01eb11b13..0397422cd 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -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)['channel-1'].channelId, 'channel-1'); + assert.equal((result.current[0].enabled as Dictionary)['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 () => { diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 0f4424d28..f4375a6f7 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -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); diff --git a/app/products/calls/actions/index.ts b/app/products/calls/actions/index.ts index d443dd440..b34c779a2 100644 --- a/app/products/calls/actions/index.ts +++ b/app/products/calls/actions/index.ts @@ -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'; diff --git a/app/products/calls/actions/permissions.test.ts b/app/products/calls/actions/permissions.test.ts new file mode 100644 index 000000000..b6b1dbfa7 --- /dev/null +++ b/app/products/calls/actions/permissions.test.ts @@ -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) => 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) => options.default); + jest.mocked(Permissions.check).mockResolvedValue(Permissions.RESULTS.GRANTED); + await hasMicrophonePermission(); + expect(Permissions.check).toHaveBeenCalledWith(Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO); + }); + }); +}); diff --git a/app/products/calls/alerts.test.ts b/app/products/calls/alerts.test.ts new file mode 100644 index 000000000..832680806 --- /dev/null +++ b/app/products/calls/alerts.test.ts @@ -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); + }); + }); +}); diff --git a/app/products/calls/calls_manager.test.ts b/app/products/calls/calls_manager.test.ts new file mode 100644 index 000000000..e3bf695fd --- /dev/null +++ b/app/products/calls/calls_manager.test.ts @@ -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'); + }); +}); diff --git a/app/products/calls/client/rest.test.ts b/app/products/calls/client/rest.test.ts new file mode 100644 index 000000000..82a6e7f4b --- /dev/null +++ b/app/products/calls/client/rest.test.ts @@ -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'}, + ); + }); + }); +}); diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index a6cb3f84f..0454621fd 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -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}, ); diff --git a/app/products/calls/connection/connection.test.ts b/app/products/calls/connection/connection.test.ts index 1cb602d16..c9d733078 100644 --- a/app/products/calls/connection/connection.test.ts +++ b/app/products/calls/connection/connection.test.ts @@ -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 = {}; + + // 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 = {}; + + // 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(); + }); }); diff --git a/app/products/calls/connection/websocket_client.test.ts b/app/products/calls/connection/websocket_client.test.ts new file mode 100644 index 000000000..75f7c76bf --- /dev/null +++ b/app/products/calls/connection/websocket_client.test.ts @@ -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 + }); +}); diff --git a/app/products/calls/connection/websocket_client.ts b/app/products/calls/connection/websocket_client.ts index 73a865953..02a00150f 100644 --- a/app/products/calls/connection/websocket_client.ts +++ b/app/products/calls/connection/websocket_client.ts @@ -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'); diff --git a/app/products/calls/connection/websocket_event_handlers.test.ts b/app/products/calls/connection/websocket_event_handlers.test.ts new file mode 100644 index 000000000..c8f203808 --- /dev/null +++ b/app/products/calls/connection/websocket_event_handlers.test.ts @@ -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); + 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); + 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); + 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); + 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); + 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); + 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); + expect(setUserMuted).toHaveBeenCalledWith(serverUrl, channelId, sessionId, true); + }); + + it('should handle user unmuted', () => { + handleCallUserUnmuted(serverUrl, { + broadcast: {channel_id: channelId}, + data: {session_id: sessionId}, + } as WebSocketMessage); + 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); + expect(setUserVoiceOn).toHaveBeenCalledWith(channelId, sessionId, true); + }); + + it('should handle voice off', () => { + handleCallUserVoiceOff({ + broadcast: {channel_id: channelId}, + data: {session_id: sessionId}, + } as WebSocketMessage); + 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); + 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); + 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); + expect(setChannelEnabled).toHaveBeenCalledWith(serverUrl, channelId, true); + }); + + it('should handle channel disabled', () => { + handleCallChannelDisabled(serverUrl, { + broadcast: {channel_id: channelId}, + data: {}, + } as WebSocketMessage); + 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); + expect(setCallScreenOn).toHaveBeenCalledWith(serverUrl, channelId, sessionId); + }); + + it('should handle screen off', () => { + handleCallScreenOff(serverUrl, { + broadcast: {channel_id: channelId}, + data: {session_id: sessionId}, + } as WebSocketMessage); + 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); + 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); + 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, + user_id: '', + team_id: '', + channel_id: channelId, + }, + data: reactionData, + } as WebSocketMessage); + 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, + user_id: '', + team_id: '', + channel_id: channelId, + }, + data: reactionData, + } as WebSocketMessage); + 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); + 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); + 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); + 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); + 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); + expect(unraiseHand).not.toHaveBeenCalled(); + }); + + it('should handle host removed', () => { + handleHostRemoved(serverUrl, { + broadcast: {channel_id: channelId}, + data: {channel_id: channelId, session_id: sessionId}, + } as WebSocketMessage); + 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); + 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); + + 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); + + 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); + + 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); + + 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); + + 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, + user_id: '', + team_id: '', + channel_id: channelId, + }, + data: captionData, + } as WebSocketMessage); + + 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); + + 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); + }); + }); +}); diff --git a/app/products/calls/hooks.test.ts b/app/products/calls/hooks.test.ts new file mode 100644 index 000000000..7a20e625d --- /dev/null +++ b/app/products/calls/hooks.test.ts @@ -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, + })); + }); + }); +}); diff --git a/app/products/calls/observers/index.test.ts b/app/products/calls/observers/index.test.ts new file mode 100644 index 000000000..c8ce4dcb1 --- /dev/null +++ b/app/products/calls/observers/index.test.ts @@ -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); + }); + }); +}); diff --git a/app/products/calls/observers/index.ts b/app/products/calls/observers/index.ts index bb4a1b100..a32e17623 100644 --- a/app/products/calls/observers/index.ts +++ b/app/products/calls/observers/index.ts @@ -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(), ); diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 6db8a6c75..bcaec7d73 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -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, diff --git a/app/products/calls/utils.test.ts b/app/products/calls/utils.test.ts index c9465fa46..b641d4f7d 100644 --- a/app/products/calls/utils.test.ts +++ b/app/products/calls/utils.test.ts @@ -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;