mattermost-mobile-upstream/app/products/calls/hooks.test.ts
2026-09-03 19:44:10 +00:00

574 lines
22 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, renderHook, waitFor} from '@testing-library/react-native';
import {Alert, AppState} from 'react-native';
import Permissions from 'react-native-permissions';
import {of as of$} from 'rxjs';
import {initializeVoiceTrack, openOutgoingCallScreen} from '@calls/actions/calls';
import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts';
import {observeIsCallLimitRestricted} from '@calls/observers';
import {
cancelOutgoingCall,
getCurrentCall,
getCallsConfig,
setMicPermissionsGranted,
useCallsState,
useChannelsWithCalls,
useCurrentCall,
useGlobalCallsState,
useIncomingCalls,
} from '@calls/state';
import {General, Preferences, Screens} from '@constants';
import {
CURRENT_CALL_BAR_HEIGHT,
JOIN_CALL_BAR_HEIGHT,
} from '@constants/view';
import {getDefaultThemeByAppearance, useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {queryAllActiveServers} from '@queries/app/servers';
import {getCurrentUser} from '@queries/servers/user';
import {navigateToScreen} from '@screens/navigation';
import {openUserProfile} from '@utils/navigation';
import {useTryCallsFunction, usePermissionsChecker, useCallsAdjustment, useHostControlsAvailable, useHostMenus, useNavigationHeaderCallButtonForDM} from './hooks';
jest.mock('react-intl', () => ({
useIntl: jest.fn().mockReturnValue({
formatMessage: jest.fn(({defaultMessage}) => defaultMessage),
}),
defineMessage: (message: unknown) => message,
defineMessages: (messages: unknown) => messages,
}));
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => 'server1'),
}));
jest.mock('@context/theme', () => ({
useTheme: jest.fn(),
getDefaultThemeByAppearance: jest.fn(),
}));
jest.mocked(useTheme).mockReturnValue(Preferences.THEMES.denim);
jest.mocked(getDefaultThemeByAppearance).mockReturnValue(Preferences.THEMES.denim);
jest.mock('@calls/actions/calls', () => ({
initializeVoiceTrack: jest.fn(),
openOutgoingCallScreen: jest.fn(),
}));
jest.mock('@calls/alerts', () => ({
leaveAndJoinWithAlert: jest.fn(),
showLimitRestrictedAlert: jest.fn(),
}));
jest.mock('@calls/observers', () => ({
observeIsCallLimitRestricted: jest.fn(),
}));
jest.mock('@calls/state', () => ({
cancelOutgoingCall: jest.fn(),
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', () => ({
navigateToScreen: jest.fn(),
}));
jest.mock('@utils/navigation');
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} = renderHook(() => useHostControlsAvailable());
await waitFor(() => 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(navigateToScreen).toHaveBeenCalledWith(Screens.CALL_HOST_CONTROLS, {sessionId: 'session1'});
});
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(openUserProfile).toHaveBeenCalledWith(
expect.objectContaining({
userId: mockSession.userId,
}),
);
});
});
describe('useNavigationHeaderCallButtonForDM', () => {
const channelId = 'channel1';
const notLimitRestricted = {limitRestricted: false, maxParticipants: 8, isCloudStarter: false};
beforeEach(() => {
(useChannelsWithCalls as jest.Mock).mockReturnValue({});
(useCurrentCall as jest.Mock).mockReturnValue(null);
(NetworkManager.getClient as jest.Mock).mockReturnValue({getEnabled: jest.fn().mockResolvedValue(true)});
jest.mocked(leaveAndJoinWithAlert).mockResolvedValue(true);
(observeIsCallLimitRestricted as jest.Mock).mockReturnValue(of$(notLimitRestricted));
});
it('should return the start call button when there is no call in the channel', () => {
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
expect(result.current).toEqual(expect.objectContaining({
iconName: 'phone',
accessibilityLabel: 'Start call',
disabled: false,
isLoading: false,
testID: 'channel_header.quick_call.button',
}));
});
it('should return the join call button when a call is ongoing in the channel', () => {
(useChannelsWithCalls as jest.Mock).mockReturnValue({[channelId]: true});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
expect(result.current).toEqual(expect.objectContaining({
iconName: 'phone-in-talk',
accessibilityLabel: 'Join call',
}));
});
it('should return the return to call button when already in this channel call', () => {
(useChannelsWithCalls as jest.Mock).mockReturnValue({[channelId]: true});
(useCurrentCall as jest.Mock).mockReturnValue({channelId});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
expect(result.current).toEqual(expect.objectContaining({
iconName: 'phone-in-talk',
accessibilityLabel: 'Return to call',
}));
});
it('should return the start call button when in a call in a different channel', () => {
(useCurrentCall as jest.Mock).mockReturnValue({channelId: 'other-channel'});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
expect(result.current?.iconName).toBe('phone');
});
it.each([General.GM_CHANNEL, General.OPEN_CHANNEL, General.PRIVATE_CHANNEL])('should return undefined for %s channels', (channelType) => {
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, channelType as ChannelType));
expect(result.current).toBeUndefined();
});
it('should start the call and open the full screen call view on press', async () => {
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).toHaveBeenCalledWith('server1', channelId);
expect(leaveAndJoinWithAlert).toHaveBeenCalledWith(expect.anything(), 'server1', channelId);
// Already opened up front, so it isn't opened a second time once connected.
expect(navigateToScreen).not.toHaveBeenCalled();
expect(cancelOutgoingCall).not.toHaveBeenCalled();
});
it('should open the call view on the press itself, without waiting on the server', async () => {
let resolveEnabled: () => void = () => null;
(NetworkManager.getClient as jest.Mock).mockReturnValue({
getEnabled: jest.fn().mockReturnValue(new Promise<boolean>((resolve) => {
resolveEnabled = () => resolve(true);
})),
});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
act(() => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).toHaveBeenCalledWith('server1', channelId);
expect(leaveAndJoinWithAlert).not.toHaveBeenCalled();
await act(async () => {
resolveEnabled();
});
});
it('should close the call view again when the call cannot be started', async () => {
jest.mocked(leaveAndJoinWithAlert).mockResolvedValue(false);
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).toHaveBeenCalled();
expect(cancelOutgoingCall).toHaveBeenCalledWith('server1', channelId);
});
it('should close the call view again when calls is not enabled on the server', async () => {
(NetworkManager.getClient as jest.Mock).mockReturnValue({getEnabled: jest.fn().mockResolvedValue(false)});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).toHaveBeenCalled();
expect(leaveAndJoinWithAlert).not.toHaveBeenCalled();
expect(cancelOutgoingCall).toHaveBeenCalledWith('server1', channelId);
});
it('should wait until connected to open the call view when joining an existing call', async () => {
(useChannelsWithCalls as jest.Mock).mockReturnValue({[channelId]: true});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).not.toHaveBeenCalled();
expect(navigateToScreen).toHaveBeenCalledWith(Screens.CALL);
});
it('should wait until connected to open the call view when leaving another call for this one', async () => {
// Switching calls prompts for confirmation first, and leaving the old call would pop
// the call view straight back off.
(useCurrentCall as jest.Mock).mockReturnValue({channelId: 'other-channel'});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(openOutgoingCallScreen).not.toHaveBeenCalled();
expect(navigateToScreen).toHaveBeenCalledWith(Screens.CALL);
});
it('should navigate to the call screen instead of joining when already in this channel call', async () => {
(useCurrentCall as jest.Mock).mockReturnValue({channelId});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(navigateToScreen).toHaveBeenCalledWith(Screens.CALL);
expect(leaveAndJoinWithAlert).not.toHaveBeenCalled();
});
it('should show the loading state while connecting to the call', async () => {
let resolveJoin: () => void = () => null;
jest.mocked(leaveAndJoinWithAlert).mockReturnValue(new Promise<boolean>((resolve) => {
resolveJoin = () => resolve(true);
}));
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
act(() => {
result.current?.onPress();
});
await waitFor(() => {
expect(result.current?.isLoading).toBe(true);
});
expect(result.current?.disabled).toBe(true);
await act(async () => {
resolveJoin();
});
expect(result.current?.isLoading).toBe(false);
expect(result.current?.disabled).toBe(false);
});
it('should show the loading state while checking whether calls is enabled', async () => {
let resolveEnabled: () => void = () => null;
(NetworkManager.getClient as jest.Mock).mockReturnValue({
getEnabled: jest.fn().mockReturnValue(new Promise<boolean>((resolve) => {
resolveEnabled = () => resolve(true);
})),
});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
act(() => {
result.current?.onPress();
});
// The button is busy before joinOrStart runs, so a second press cannot start another call.
await waitFor(() => {
expect(result.current?.isLoading).toBe(true);
});
expect(result.current?.disabled).toBe(true);
expect(leaveAndJoinWithAlert).not.toHaveBeenCalled();
await act(async () => {
resolveEnabled();
});
expect(leaveAndJoinWithAlert).toHaveBeenCalledTimes(1);
expect(result.current?.isLoading).toBe(false);
expect(result.current?.disabled).toBe(false);
});
it('should show the capacity alert and not join when the call is at the participant limit', async () => {
const limitRestrictedInfo = {limitRestricted: true, maxParticipants: 8, isCloudStarter: false};
(observeIsCallLimitRestricted as jest.Mock).mockReturnValue(of$(limitRestrictedInfo));
(useChannelsWithCalls as jest.Mock).mockReturnValue({[channelId]: true});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
expect(showLimitRestrictedAlert).toHaveBeenCalledWith(limitRestrictedInfo, expect.anything());
expect(leaveAndJoinWithAlert).not.toHaveBeenCalled();
expect(result.current?.isLoading).toBe(false);
});
it('should only join once when the button is pressed twice in quick succession', async () => {
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
result.current?.onPress();
});
expect(leaveAndJoinWithAlert).toHaveBeenCalledTimes(1);
});
it('should only navigate once when the return to call button is pressed twice in quick succession', async () => {
(useCurrentCall as jest.Mock).mockReturnValue({channelId});
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
result.current?.onPress();
});
expect(navigateToScreen).toHaveBeenCalledTimes(1);
});
it('should clear the loading state when joining the call rejects', async () => {
jest.mocked(leaveAndJoinWithAlert).mockRejectedValue(new Error('failed to join'));
const {result} = renderHook(() => useNavigationHeaderCallButtonForDM(channelId, General.DM_CHANNEL));
await act(async () => {
result.current?.onPress();
});
await waitFor(() => {
expect(result.current?.isLoading).toBe(false);
});
expect(result.current?.disabled).toBe(false);
expect(navigateToScreen).not.toHaveBeenCalled();
});
});
});