// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import CallsNative, { type CallActionPayload, type CallMutePayload, type IncomingCallPayload, type VoIPTokenUpdated, } from '@mattermost/calls-native'; import {Platform} from 'react-native'; import {storeVoIPDeviceToken} from '@actions/app/global'; import {dismissIncomingCall, hasMicrophonePermission, joinCall, leaveCall, muteMyself, unmuteMyself} from '@calls/actions'; import {hasBluetoothPermission} from '@calls/actions/permissions'; import { clearNativeCallMapping, getNativeCallMapping, setNativeCallMapping, } from '@calls/native_call'; import {getCurrentCall, setMicPermissionsGranted} from '@calls/state'; import {Device} from '@constants'; import DatabaseManager from '@database/manager'; import {getServerByIdentifier} from '@queries/app/servers'; import {getCurrentUser} from '@queries/servers/user'; jest.mock('@actions/app/global', () => ({ storeVoIPDeviceToken: jest.fn(), })); jest.mock('@actions/remote/channel', () => ({ switchToChannelById: jest.fn(), })); jest.mock('@calls/actions', () => ({ dismissIncomingCall: jest.fn(), hasMicrophonePermission: jest.fn(), joinCall: jest.fn(), leaveCall: jest.fn(), muteMyself: jest.fn(), unmuteMyself: jest.fn(), })); jest.mock('@calls/actions/permissions', () => ({ hasBluetoothPermission: jest.fn(), })); jest.mock('@calls/native_call', () => ({ clearNativeCallMapping: jest.fn(), getNativeCallMapping: jest.fn(), setNativeCallMapping: jest.fn(), })); jest.mock('@managers/websocket_manager', () => ({ __esModule: true, default: { initializeClient: jest.fn(), }, })); jest.mock('@calls/state', () => ({ getCurrentCall: jest.fn(), setMicPermissionsGranted: jest.fn(), })); jest.mock('@queries/app/servers', () => ({ getServerByIdentifier: jest.fn(), })); jest.mock('@queries/servers/user', () => ({ getCurrentUser: jest.fn(), })); jest.mock('@utils/log', () => ({ logDebug: jest.fn(), logError: jest.fn(), logInfo: jest.fn(), })); const SERVER_URL = 'https://example.com'; // Loads (or reloads) the singleton and returns the handlers the init() // call registered, so we can drive each event handler directly. const loadAndInit = () => { jest.isolateModules(() => { // Require inside isolateModules so resets don't leak across tests. const CallsNativeInit = require('./calls_native').default; CallsNativeInit.init(); }); const calls = (CallsNative as any); return { onVoIPTokenUpdated: calls.onVoIPTokenUpdated.mock.calls.at(-1)?.[0] as (e: VoIPTokenUpdated) => Promise, onIncomingCall: calls.onIncomingCall.mock.calls.at(-1)?.[0] as (e: IncomingCallPayload) => Promise, onCallAnswered: calls.onCallAnswered.mock.calls.at(-1)?.[0] as (e: CallActionPayload) => Promise, onCallDeclined: calls.onCallDeclined.mock.calls.at(-1)?.[0] as (e: CallActionPayload) => Promise, onCallEnded: calls.onCallEnded.mock.calls.at(-1)?.[0] as (e: CallActionPayload) => void, onMuteChanged: calls.onMuteChanged.mock.calls.at(-1)?.[0] as (e: CallMutePayload) => void, }; }; let originalOS: typeof Platform.OS; beforeEach(() => { jest.clearAllMocks(); originalOS = Platform.OS; Object.defineProperty(Platform, 'OS', {get: () => 'ios'}); }); afterEach(() => { Object.defineProperty(Platform, 'OS', {get: () => originalOS}); }); describe('init/cleanup', () => { it('subscribes to all six events on iOS', () => { loadAndInit(); expect(CallsNative.onVoIPTokenUpdated).toHaveBeenCalledTimes(1); expect(CallsNative.onIncomingCall).toHaveBeenCalledTimes(1); expect(CallsNative.onCallAnswered).toHaveBeenCalledTimes(1); expect(CallsNative.onCallDeclined).toHaveBeenCalledTimes(1); expect(CallsNative.onCallEnded).toHaveBeenCalledTimes(1); expect(CallsNative.onMuteChanged).toHaveBeenCalledTimes(1); }); it('is a no-op on Android', () => { Object.defineProperty(Platform, 'OS', {get: () => 'android'}); loadAndInit(); expect(CallsNative.onVoIPTokenUpdated).not.toHaveBeenCalled(); expect(CallsNative.onIncomingCall).not.toHaveBeenCalled(); }); }); describe('onVoIPTokenUpdated', () => { it('stores empty string when token is empty (invalidation)', async () => { const {onVoIPTokenUpdated} = loadAndInit(); await onVoIPTokenUpdated({token: ''}); expect(storeVoIPDeviceToken).toHaveBeenCalledWith(''); }); // The shared expo-application mock in test/setup.ts sets applicationId // to 'com.mattermost.rnbeta', so isBetaApp is true by default in tests. it('stores beta-prefixed token under the default (beta) test config', async () => { const {onVoIPTokenUpdated} = loadAndInit(); await onVoIPTokenUpdated({token: 'abc123'}); expect(storeVoIPDeviceToken).toHaveBeenCalledWith(`${Device.PUSH_NOTIFY_APPLE_REACT_NATIVE}beta-v2:abc123`); }); it('stores non-beta-prefixed token when isBetaApp is false', async () => { // Override isBetaApp to false for this test only. let handler: ((e: VoIPTokenUpdated) => Promise) | undefined; jest.isolateModules(() => { jest.doMock('@utils/general', () => { const actual = jest.requireActual('@utils/general'); return { ...actual, isBetaApp: false, }; }); const CallsNativeInit = require('./calls_native').default; CallsNativeInit.init(); const mockedOnUpdated = CallsNative.onVoIPTokenUpdated as jest.Mock; const lastCall = mockedOnUpdated.mock.calls[mockedOnUpdated.mock.calls.length - 1]; handler = lastCall?.[0]; }); await handler!({token: 'abc123'}); expect(storeVoIPDeviceToken).toHaveBeenCalledWith(`${Device.PUSH_NOTIFY_APPLE_REACT_NATIVE}-v2:abc123`); }); }); describe('onIncomingCall', () => { it('ignores payload missing serverId', async () => { const {onIncomingCall} = loadAndInit(); await onIncomingCall({uuid: 'u1', serverId: '', channelId: 'ch1', postId: 'p1', threadId: 't1'} as IncomingCallPayload); expect(setNativeCallMapping).not.toHaveBeenCalled(); expect(CallsNative.reportEnded).not.toHaveBeenCalled(); }); it('ignores payload missing channelId', async () => { const {onIncomingCall} = loadAndInit(); await onIncomingCall({uuid: 'u1', serverId: 's1', channelId: '', postId: '', threadId: ''} as IncomingCallPayload); expect(setNativeCallMapping).not.toHaveBeenCalled(); expect(CallsNative.reportEnded).not.toHaveBeenCalled(); }); it('reports ended when the serverId cannot be resolved', async () => { (getServerByIdentifier as jest.Mock).mockResolvedValueOnce(null); const {onIncomingCall} = loadAndInit(); await onIncomingCall({ uuid: 'u1', serverId: 'unknown', channelId: 'ch1', postId: '', threadId: '', callerId: '', callerName: '', }); expect(CallsNative.reportEnded).toHaveBeenCalledWith('u1', 'failed'); expect(setNativeCallMapping).not.toHaveBeenCalled(); }); it('stores the mapping for a valid payload', async () => { (getServerByIdentifier as jest.Mock).mockResolvedValueOnce({url: SERVER_URL}); const {onIncomingCall} = loadAndInit(); await onIncomingCall({ uuid: 'u1', serverId: 's1', channelId: 'ch1', postId: 'p1', threadId: 't1', callerId: '', callerName: '', }); expect(setNativeCallMapping).toHaveBeenCalledWith('u1', { serverUrl: SERVER_URL, channelId: 'ch1', postId: 'p1', threadId: 't1', }); expect(CallsNative.reportEnded).not.toHaveBeenCalled(); }); it('opens the WS to the call server so live events arrive while ringing', async () => { const WebsocketManager = require('@managers/websocket_manager').default; (getServerByIdentifier as jest.Mock).mockResolvedValueOnce({url: SERVER_URL}); const {onIncomingCall} = loadAndInit(); await onIncomingCall({ uuid: 'u1', serverId: 's1', channelId: 'ch1', postId: 'p1', threadId: 't1', callerId: '', callerName: '', }); expect(WebsocketManager.initializeClient).toHaveBeenCalledWith(SERVER_URL); }); it('does not open a WS when the serverId cannot be resolved', async () => { const WebsocketManager = require('@managers/websocket_manager').default; (getServerByIdentifier as jest.Mock).mockResolvedValueOnce(null); const {onIncomingCall} = loadAndInit(); await onIncomingCall({ uuid: 'u1', serverId: 'unknown', channelId: 'ch1', postId: '', threadId: '', callerId: '', callerName: '', }); expect(WebsocketManager.initializeClient).not.toHaveBeenCalled(); }); }); describe('onCallAnswered', () => { const mapping = {serverUrl: SERVER_URL, channelId: 'ch1', postId: 'p1', threadId: 't1'}; beforeEach(async () => { await DatabaseManager.init([SERVER_URL]); }); afterEach(async () => { await DatabaseManager.destroyServerDatabase(SERVER_URL); }); it('reports ended when no mapping exists for the uuid', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(undefined); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(CallsNative.reportEnded).toHaveBeenCalledWith('u1', 'failed'); expect(joinCall).not.toHaveBeenCalled(); }); it('reports ended and clears mapping when there is no current user', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce(null); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(CallsNative.reportEnded).toHaveBeenCalledWith('u1', 'failed'); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); expect(joinCall).not.toHaveBeenCalled(); }); it('leaves an active call on a different channel before joining', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'other-channel'}); (hasMicrophonePermission as jest.Mock).mockResolvedValueOnce(true); (joinCall as jest.Mock).mockResolvedValueOnce({}); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(leaveCall).toHaveBeenCalledTimes(1); expect(joinCall).toHaveBeenCalled(); }); it('does not leave when the current call matches the answered call', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'ch1'}); (hasMicrophonePermission as jest.Mock).mockResolvedValueOnce(true); (joinCall as jest.Mock).mockResolvedValueOnce({}); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(leaveCall).not.toHaveBeenCalled(); }); it('reports ended and clears mapping when joinCall returns an error', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce(null); (hasMicrophonePermission as jest.Mock).mockResolvedValueOnce(true); (joinCall as jest.Mock).mockResolvedValueOnce({error: 'boom'}); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(CallsNative.reportEnded).toHaveBeenCalledWith('u1', 'failed'); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); expect(CallsNative.reportConnected).not.toHaveBeenCalled(); }); it('reports connected and unmutes on successful join', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce(null); (hasMicrophonePermission as jest.Mock).mockResolvedValueOnce(true); (joinCall as jest.Mock).mockResolvedValueOnce({}); jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValueOnce(SERVER_URL); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(setMicPermissionsGranted).toHaveBeenCalledWith(true); expect(joinCall).toHaveBeenCalledWith(SERVER_URL, 'ch1', 'user1', true, expect.anything(), undefined, 't1'); expect(CallsNative.reportConnected).toHaveBeenCalledWith('u1'); expect(unmuteMyself).toHaveBeenCalled(); expect(CallsNative.setMuted).not.toHaveBeenCalled(); }); it('switches the active server when answering for a different server than the active one', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce(null); (hasMicrophonePermission as jest.Mock).mockResolvedValueOnce(true); (joinCall as jest.Mock).mockResolvedValueOnce({}); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); }); it('catches thrown errors, reports ended, and clears mapping', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentUser as jest.Mock).mockResolvedValueOnce({id: 'user1', locale: 'en'}); (getCurrentCall as jest.Mock).mockReturnValueOnce(null); (hasBluetoothPermission as jest.Mock).mockRejectedValueOnce(new Error('permission crash')); const {onCallAnswered} = loadAndInit(); await onCallAnswered({uuid: 'u1'}); expect(CallsNative.reportEnded).toHaveBeenCalledWith('u1', 'failed'); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); }); }); describe('onCallDeclined', () => { it('does nothing when no mapping exists', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(undefined); const {onCallDeclined} = loadAndInit(); await onCallDeclined({uuid: 'u1'}); expect(dismissIncomingCall).not.toHaveBeenCalled(); }); it('clears mapping and dismisses the incoming call when mapping exists', async () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce({ serverUrl: SERVER_URL, channelId: 'ch1', postId: '', threadId: '', }); const {onCallDeclined} = loadAndInit(); await onCallDeclined({uuid: 'u1'}); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); expect(dismissIncomingCall).toHaveBeenCalledWith(SERVER_URL, 'ch1'); }); }); describe('onCallEnded', () => { it('does nothing when no mapping exists', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(undefined); const {onCallEnded} = loadAndInit(); onCallEnded({uuid: 'u1'}); expect(leaveCall).not.toHaveBeenCalled(); }); it('leaves the call when the current call matches the ended mapping', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce({ serverUrl: SERVER_URL, channelId: 'ch1', postId: '', threadId: '', }); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'ch1'}); const {onCallEnded} = loadAndInit(); onCallEnded({uuid: 'u1'}); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); expect(leaveCall).toHaveBeenCalledTimes(1); }); it('does not leave when the current call is on a different channel', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce({ serverUrl: SERVER_URL, channelId: 'ch1', postId: '', threadId: '', }); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'other-channel'}); const {onCallEnded} = loadAndInit(); onCallEnded({uuid: 'u1'}); expect(clearNativeCallMapping).toHaveBeenCalledWith('u1'); expect(leaveCall).not.toHaveBeenCalled(); }); }); describe('onMuteChanged', () => { const mapping = {serverUrl: SERVER_URL, channelId: 'ch1', postId: '', threadId: ''}; it('mutes the JS side when native reports muted=true', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'ch1'}); const {onMuteChanged} = loadAndInit(); onMuteChanged({uuid: 'u1', muted: true}); expect(muteMyself).toHaveBeenCalled(); expect(unmuteMyself).not.toHaveBeenCalled(); }); it('unmutes the JS side when native reports muted=false', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'ch1'}); const {onMuteChanged} = loadAndInit(); onMuteChanged({uuid: 'u1', muted: false}); expect(unmuteMyself).toHaveBeenCalled(); expect(muteMyself).not.toHaveBeenCalled(); }); it('does nothing when no mapping exists', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(undefined); const {onMuteChanged} = loadAndInit(); onMuteChanged({uuid: 'u1', muted: true}); expect(muteMyself).not.toHaveBeenCalled(); expect(unmuteMyself).not.toHaveBeenCalled(); }); it('does nothing when the current call does not match the mapping', () => { (getNativeCallMapping as jest.Mock).mockReturnValueOnce(mapping); (getCurrentCall as jest.Mock).mockReturnValueOnce({serverUrl: SERVER_URL, channelId: 'other-channel'}); const {onMuteChanged} = loadAndInit(); onMuteChanged({uuid: 'u1', muted: true}); expect(muteMyself).not.toHaveBeenCalled(); expect(unmuteMyself).not.toHaveBeenCalled(); }); });