mattermost-mobile-upstream/app/init/calls_native.test.ts
Bill Gardner 827eae876c
[MM-70525] Fix CallKit call disconnect on lock-screen answer (#10111)
* [MM-70525] Fix CallKit call disconnect on lock-screen answer

NativeCallMapping gains an optional callId field. When a VoIP push
arrives the mapping is created with callId seeded from calls state
(empty string if call_started hasn't been processed yet). handleCallStarted
backfills the callId once the WebSocket event confirms the call is live.

getNativeCallUUIDForCall now accepts an optional callId: when provided,
a mapping whose callId is empty or mismatched is treated as unconfirmed
and returns undefined, making endNativeCall a no-op. This prevents a
buffered call_end event from a prior call being replayed on WebSocket
reconnect from tearing down the active CallKit session.

handleCallEnded reads the callId from calls state before callEnded()
removes it, then passes it through to endNativeCall so the guard can
distinguish the current call from a stale replayed event.

* [MM-70525] Add handler-level tests for callId backfill and stale replay guard

Tests handleCallStarted backfilling callId into an existing native mapping
(VoIP-push-first ordering), handleCallEnded reading callId from state before
callEnded() clears it, and the stale call_end replay path where no active call
is in state.

* [MM-70525] Tighten callId ordering test: callEnded mock clears state

Makes the callEnded mock simulate state cleanup so the test fails if
callId is read after callEnded() rather than before it.
2026-09-08 14:17:56 -04:00

498 lines
20 KiB
TypeScript

// 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, getCallsState, 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(),
getCallsState: jest.fn(() => ({calls: {}})),
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<void>,
onIncomingCall: calls.onIncomingCall.mock.calls.at(-1)?.[0] as (e: IncomingCallPayload) => Promise<void>,
onCallAnswered: calls.onCallAnswered.mock.calls.at(-1)?.[0] as (e: CallActionPayload) => Promise<void>,
onCallDeclined: calls.onCallDeclined.mock.calls.at(-1)?.[0] as (e: CallActionPayload) => Promise<void>,
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<void>) | 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',
callId: '',
});
expect(CallsNative.reportEnded).not.toHaveBeenCalled();
});
it('seeds callId from state when call_started arrived before the VoIP push', async () => {
(getServerByIdentifier as jest.Mock).mockResolvedValueOnce({url: SERVER_URL});
jest.mocked(getCallsState).mockReturnValueOnce({calls: {'ch1': {id: 'call-1'}}} as any);
const {onIncomingCall} = loadAndInit();
await onIncomingCall({
uuid: 'u1',
serverId: 's1',
channelId: 'ch1',
postId: '',
threadId: '',
callerId: '',
callerName: '',
});
expect(setNativeCallMapping).toHaveBeenCalledWith('u1', expect.objectContaining({callId: 'call-1'}));
});
it('sets empty callId when call_started has not yet arrived', async () => {
(getServerByIdentifier as jest.Mock).mockResolvedValueOnce({url: SERVER_URL});
jest.mocked(getCallsState).mockReturnValueOnce({calls: {}} as any);
const {onIncomingCall} = loadAndInit();
await onIncomingCall({
uuid: 'u1',
serverId: 's1',
channelId: 'ch1',
postId: '',
threadId: '',
callerId: '',
callerName: '',
});
expect(setNativeCallMapping).toHaveBeenCalledWith('u1', expect.objectContaining({callId: ''}));
});
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();
});
});