From c1b4b102f60679c167b93a41a7b4d5722cd9a2c5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 14:06:58 -0300 Subject: [PATCH 01/12] =?UTF-8?q?test(NewMediaCall):=20add=20integration?= =?UTF-8?q?=20tests=20for=20NewMediaCall=20=E2=86=92=20CallView=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify that pressing the Call button drives the full pipeline: - session.startCall called at SDK boundary with correct actor/userId - Navigation.navigate('CallView') dispatched - useCallStore.call populated via the real setCall action - emitter wired at both SDK-layer and store-layer Test 5 additionally proves CallView renders its container when the store is populated via setCall, completing the two-halves proof: press → store update + navigation, and store update → view renders. --- .../NewMediaCall.integration.test.tsx | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 app/containers/NewMediaCall/NewMediaCall.integration.test.tsx diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx new file mode 100644 index 00000000000..68bd1d1b0bd --- /dev/null +++ b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx @@ -0,0 +1,407 @@ +// app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +// Integration test: pressing the Call button in NewMediaCall triggers the +// media-signaling pipeline that opens CallView. Mocks @rocket.chat/media-signaling +// at the SDK boundary; does NOT mock mediaSessionInstance (that's the whole point). +// +// Seam: the real MediaSessionInstance runs. When the SDK fires `newCall` +// (simulated here by invoking the registered handler), we verify +// - session.startCall was called with SDK-boundary args (actor, userId) +// - Navigation.navigate('CallView') was called +// - useCallStore.call was populated via the real setCall action +// - emitter.on was wired at both SDK-layer (MediaSessionInstance) and store-layer (useCallStore) +// Test 5 separately proves CallView renders when useCallStore.getState().setCall(call) runs. + +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + +import { NewMediaCall } from './NewMediaCall'; +import CallView from '../../views/CallView'; +import Navigation from '../../lib/navigation/appNavigation'; +import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; +import { useCallStore } from '../../lib/services/voip/useCallStore'; +import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; +import { mockedStore } from '../../reducers/mockedStore'; +import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; +import type { InsideStackParamList } from '../../stacks/types'; + +// Compile-time route-name guard — renaming 'CallView' in the stacks breaks tsc. +// (CallView: undefined is defined at app/stacks/types.ts:299) +// `void` prevents the noUnusedLocals error; the type annotation is the actual guard. +type AssertCallViewRoute = InsideStackParamList extends { CallView: unknown } ? true : never; +const _routeCheck: AssertCallViewRoute = true; +void _routeCheck; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +// The jest moduleNameMapper covers app/lib/database only for the main repo root. +// Worktree paths (.claude/worktrees/quirky-euclid/app/lib/database) aren't matched, +// so we mock it per-file to prevent appGroup.ts from calling NativeModules.AppGroup at load. + +let consoleErrorSpy: ReturnType | undefined; +jest.mock('../../lib/database', () => ({ + db: { get: jest.fn() }, + active: { get: jest.fn() } +})); +jest.mock('../../lib/database/services/Subscription', () => ({ + getDMSubscriptionByUsername: jest.fn().mockResolvedValue(null) +})); + +jest.mock('../../lib/methods/helpers/helpers', () => ({ + getUidDirectMessage: jest.fn(() => 'other-user-id') +})); + +jest.mock('../../lib/navigation/appNavigation', () => ({ + __esModule: true, + default: { navigate: jest.fn(), back: jest.fn() } +})); + +jest.mock('../../lib/services/sdk', () => ({ + __esModule: true, + default: { + onStreamData: jest.fn(() => ({ stop: jest.fn() })), + methodCall: jest.fn() + } +})); + +jest.mock('../../lib/store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + settings: { + VoIP_TeamCollab_Ice_Servers: '', + VoIP_TeamCollab_Ice_Gathering_Timeout: 5000 + } + })), + subscribe: jest.fn(() => jest.fn()) + } +})); + +jest.mock('react-native-webrtc', () => ({ + registerGlobals: jest.fn(), + mediaDevices: { getUserMedia: jest.fn() } +})); + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + endCall: jest.fn(), + setCurrentCallActive: jest.fn(), + setAvailable: jest.fn() + } +})); + +jest.mock('../../lib/methods/helpers/fileDownload', () => ({ + fileDownload: jest.fn(), + fileDownloadAndPreview: jest.fn() +})); + +jest.mock('react-native-device-info', () => ({ + __esModule: true, + default: { + getUniqueId: jest.fn(() => 'test-device-id'), + getUniqueIdSync: jest.fn(() => 'test-device-id'), + hasNotch: jest.fn(() => false), + getReadableVersion: jest.fn(() => '1.0.0'), + getBundleId: jest.fn(() => 'com.rocket.chat'), + getModel: jest.fn(() => 'iPhone'), + getSystemVersion: jest.fn(() => '14.0'), + isTablet: jest.fn(() => false) + }, + getUniqueId: jest.fn(() => 'test-device-id'), + getUniqueIdSync: jest.fn(() => 'test-device-id'), + hasNotch: jest.fn(() => false), + getReadableVersion: jest.fn(() => '1.0.0'), + getBundleId: jest.fn(() => 'com.rocket.chat'), + getModel: jest.fn(() => 'iPhone'), + getSystemVersion: jest.fn(() => '14.0'), + isTablet: jest.fn(() => false) +})); + +jest.mock('../../lib/native/NativeVoip', () => ({ + __esModule: true, + default: { stopNativeDDPClient: jest.fn() } +})); + +jest.mock('../../lib/methods/voipPhoneStatePermission', () => ({ + requestPhoneStatePermission: jest.fn() +})); + +// Required because useCallStore.ts imports it; the real hook calls AccessibilityInfo +// which is not globally mocked. useControlsVisible (used in CallButtons) reads it during render. +jest.mock('../../lib/hooks/useIsScreenReaderEnabled', () => ({ + useIsScreenReaderEnabled: jest.fn(() => false) +})); + +// PeerList imports Avatar → database → appGroup (native NativeModule not available in Jest). +// FilterHeader and SelectedPeer may have similar chains. Mock them as null since they are +// not part of the integration contract (CreateCall button → MediaSessionInstance → CallView). +jest.mock('./PeerList', () => ({ PeerList: () => null })); +jest.mock('./FilterHeader', () => ({ FilterHeader: () => null })); +jest.mock('./SelectedPeer', () => ({ SelectedPeer: () => null })); + +// usePeerAutocompleteStore imports getPeerAutocompleteOptions → restApi → encryption +// → @rocket.chat/mobile-crypto (ESM, not transformable in Jest). Mock the leaf dependency +// so the real store module loads and state actions work normally. +jest.mock('../../lib/services/voip/getPeerAutocompleteOptions', () => ({ + getPeerAutocompleteOptions: jest.fn().mockResolvedValue([]) +})); + +// CallView/CallButtons imports navigateToCallRoom → goRoom → restApi → encryption → ESM fail. +// Same mock used in CallView/index.test.tsx. +jest.mock('../../lib/services/voip/navigateToCallRoom', () => ({ + navigateToCallRoom: jest.fn().mockResolvedValue(undefined) +})); + +const mockHideActionSheet = jest.fn(); +// Minimal mock — only hideActionSheetRef (used by CreateCall and useCallStore.reset) +// and showActionSheetRef (used by CallButtons in CallView) are needed. +// Avoid jest.requireActual: the real ActionSheet loads deviceInfo at module init which +// calls DeviceInfo.hasNotch() — a native method not available in Jest. +jest.mock('../ActionSheet', () => ({ + hideActionSheetRef: () => mockHideActionSheet(), + showActionSheetRef: jest.fn() +})); + +// ─── Media-signaling mock ───────────────────────────────────────────────────── + +type MockMediaSignalingSession = { + userId: string; + sessionId: string; + endSession: jest.Mock; + on: jest.Mock; + processSignal: jest.Mock; + setIceGatheringTimeout: jest.Mock; + startCall: jest.Mock; + getCallData: jest.Mock; +}; + +const createdSessions: MockMediaSignalingSession[] = []; + +jest.mock('@rocket.chat/media-signaling', () => ({ + MediaCallWebRTCProcessor: jest.fn().mockImplementation(function MediaCallWebRTCProcessor(this: unknown) { + return this; + }), + MediaSignalingSession: jest + .fn() + .mockImplementation(function MockMediaSignalingSession(this: MockMediaSignalingSession, config: { userId: string }) { + const endSession = jest.fn(); + this.userId = config.userId; + this.endSession = endSession; + this.on = jest.fn(); + this.processSignal = jest.fn().mockResolvedValue(undefined); + this.setIceGatheringTimeout = jest.fn(); + this.startCall = jest.fn().mockResolvedValue(undefined); + this.getCallData = jest.fn(); + Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false }); + createdSessions.push(this); + }) +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getNewCallHandler(): (payload: { call: IClientMediaCall }) => void { + const session = createdSessions[createdSessions.length - 1]; + if (!session) { + throw new Error('no session created'); + } + const entry = session.on.mock.calls.find(([name]: [string]) => name === 'newCall'); + if (!entry) { + throw new Error('newCall handler not registered'); + } + return entry[1] as (payload: { call: IClientMediaCall }) => void; +} + +function buildClientMediaCall(options: { + callId: string; + role: 'caller' | 'callee'; + hidden?: boolean; + contact?: { username?: string; sipExtension?: string }; +}): IClientMediaCall { + const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; + return { + callId: options.callId, + hidden: options.hidden ?? false, + state: 'ringing', + localParticipant: { local: true, role: options.role, muted: false, held: false, contact: {} }, + remoteParticipants: [ + { + local: false, + role: options.role === 'caller' ? 'callee' : 'caller', + muted: false, + held: false, + contact: options.contact ?? {} + } + ], + reject: jest.fn(), + emitter: emitter as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; +} + +// For Test 5 — must satisfy IClientMediaCall shape so useCallStore.setCall can wire it. +function createMockCall(overrides: { callId?: string } = {}): IClientMediaCall { + const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; + return { + callId: overrides.callId ?? 'mock-call-id', + state: 'active', + hidden: false, + localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, + remoteParticipants: [ + { + local: false, + role: 'callee', + muted: false, + held: false, + contact: { displayName: 'Bob', username: 'bob', sipExtension: '' } + } + ], + reject: jest.fn(), + hangup: jest.fn(), + emitter: emitter as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; +} + +function setSelectedPeer(peer: TPeerItem): void { + usePeerAutocompleteStore.setState({ selectedPeer: peer, options: [], filter: '' }); +} + +const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('NewMediaCall → CallView (integration)', () => { + beforeEach(() => { + jest.clearAllMocks(); + createdSessions.length = 0; + usePeerAutocompleteStore.getState().reset(); + useCallStore.getState().reset(); + mediaSessionInstance.reset(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mediaSessionInstance.init('me'); + expect(createdSessions).toHaveLength(1); // singleton-bleed guard + // Clear calls made during setup (reset() calls hideActionSheetRef internally) + mockHideActionSheet.mockClear(); + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + consoleErrorSpy = undefined; + mediaSessionInstance.reset(); + }); + + it('user peer: press Call → navigates to CallView, binds call, wires emitter at both layers', () => { + setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' }); + const session = createdSessions[createdSessions.length - 1]; + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + + const outgoing = buildClientMediaCall({ callId: 'c1', role: 'caller' }); + getNewCallHandler()({ call: outgoing }); + + // SDK-boundary contract: session.startCall(actor, userId) — note reversed vs public API + expect(session.startCall).toHaveBeenCalledTimes(1); + expect(session.startCall).toHaveBeenCalledWith('user', 'user-1'); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + + // Real setCall action ran — store is populated with the actual call object + expect(useCallStore.getState().call).toBe(outgoing); + + // Emitter wiring — two layers. + // outgoing.emitter.on is jest.fn() at runtime; cast to access .mock.calls. + const emitterOn = outgoing.emitter.on as unknown as jest.Mock; + + // 'stateChange': once from MediaSessionInstance.ts (SDK logging) + once from useCallStore.ts (store wiring) + const stateChangeCalls = emitterOn.mock.calls.filter(([name]: [string]) => name === 'stateChange'); + expect(stateChangeCalls).toHaveLength(2); + + // 'trackStateChange': once from useCallStore.ts + expect(emitterOn).toHaveBeenCalledWith('trackStateChange', expect.any(Function)); + + // 'ended': once from MediaSessionInstance.ts (RNCallKeep cleanup) + once from useCallStore.ts (Navigation.back) + const endedCalls = emitterOn.mock.calls.filter(([name]: [string]) => name === 'ended'); + expect(endedCalls).toHaveLength(2); + }); + + it('SIP peer: press Call → navigates to CallView and binds the call', () => { + setSelectedPeer({ type: 'sip', value: '+5511999999999', label: '+55 11 99999-9999' }); + const session = createdSessions[createdSessions.length - 1]; + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + + const outgoing = buildClientMediaCall({ callId: 'c2', role: 'caller', contact: { sipExtension: 'ext' } }); + getNewCallHandler()({ call: outgoing }); + + // SDK-boundary contract: session.startCall(actor, userId) + expect(session.startCall).toHaveBeenCalledTimes(1); + expect(session.startCall).toHaveBeenCalledWith('sip', '+5511999999999'); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + + expect(useCallStore.getState().call?.callId).toBe('c2'); + }); + + it('no peer selected: button disabled, no navigate, action sheet stays open', () => { + // do not set a peer — store starts with selectedPeer: null + const session = createdSessions[createdSessions.length - 1]; + + const { getByTestId } = render( + + + + ); + + fireEvent.press(getByTestId('new-media-call-button')); + // do NOT drive newCall + + expect(getByTestId('new-media-call-button').props.accessibilityState?.disabled).toBe(true); + expect(session.startCall).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(useCallStore.getState().call).toBeNull(); + // Only negative-path assertion: positive paths are covered by CreateCall.test.tsx + expect(mockHideActionSheet).not.toHaveBeenCalled(); + }); + + // Test 4 is a module-scope compile-time assertion (see _routeCheck above) — no it(...) block. + // Renaming 'CallView' in InsideStack.tsx or MasterDetailStack/index.tsx will break tsc here. + + it('setCall(call) populates store and CallView renders its container', () => { + const call = createMockCall({ callId: 'c5' }); + + // Use the real action — exercises emitter subscriptions, InCallManager.start, contact extraction. + // This proves the call-site (MediaSessionInstance calling setCall) produces state CallView renders, + // not merely that an arbitrary state shape renders. + useCallStore.getState().setCall(call); + + // Proves real setCall ran (not a setState bypass) + expect(call.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('call-view-container')).toBeTruthy(); + + // Teardown — setState acceptable here because we're just clearing the UI + act(() => { + useCallStore.setState({ call: null }); + }); + expect(queryByTestId('call-view-container')).toBeNull(); + }); +}); From b91b774511e850c8513aaeae52dbd67ee54e71d1 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 14:16:51 -0300 Subject: [PATCH 02/12] fix(test): resolve ESLint errors in NewMediaCall integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `void _routeCheck` (banned by no-void rule) with `(true satisfies AssertCallViewRoute)` — no binding, no unused-var - Add eslint-disable for jest/no-standalone-expect in beforeEach guard --- .../NewMediaCall/NewMediaCall.integration.test.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx index 68bd1d1b0bd..09ce6f5751b 100644 --- a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +++ b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx @@ -26,12 +26,10 @@ import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; -// Compile-time route-name guard — renaming 'CallView' in the stacks breaks tsc. -// (CallView: undefined is defined at app/stacks/types.ts:299) -// `void` prevents the noUnusedLocals error; the type annotation is the actual guard. +// Compile-time guard — fails tsc if 'CallView' is removed from InsideStackParamList. +// `satisfies` creates no binding so there is no unused-variable warning. type AssertCallViewRoute = InsideStackParamList extends { CallView: unknown } ? true : never; -const _routeCheck: AssertCallViewRoute = true; -void _routeCheck; +(true satisfies AssertCallViewRoute); // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -278,6 +276,7 @@ describe('NewMediaCall → CallView (integration)', () => { mediaSessionInstance.reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mediaSessionInstance.init('me'); + // eslint-disable-next-line jest/no-standalone-expect expect(createdSessions).toHaveLength(1); // singleton-bleed guard // Clear calls made during setup (reset() calls hideActionSheetRef internally) mockHideActionSheet.mockClear(); From fbea351e3a82b727cffff0e564253a8dbc8cd6c4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 14:18:36 -0300 Subject: [PATCH 03/12] =?UTF-8?q?fix(test):=20address=20CodeRabbit=20revie?= =?UTF-8?q?w=20=E2=80=94=20proper=20lint-safe=20type=20guard=20and=20setup?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `(true satisfies AssertCallViewRoute)` with a generic assertType function call: no-unused-expressions can't flag a call - Prefix type param with `_T` to silence unused-type-param TS error - Replace eslint-disable + expect in beforeEach with an imperative throw — satisfies jest/no-standalone-expect without suppressions --- .../NewMediaCall/NewMediaCall.integration.test.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx index 09ce6f5751b..514f8a6cafe 100644 --- a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +++ b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx @@ -27,9 +27,9 @@ import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptio import type { InsideStackParamList } from '../../stacks/types'; // Compile-time guard — fails tsc if 'CallView' is removed from InsideStackParamList. -// `satisfies` creates no binding so there is no unused-variable warning. -type AssertCallViewRoute = InsideStackParamList extends { CallView: unknown } ? true : never; -(true satisfies AssertCallViewRoute); +// Function-call form avoids both no-void and no-unused-expressions lint rules. +const assertType = <_T extends true>() => undefined; +assertType(); // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -276,8 +276,9 @@ describe('NewMediaCall → CallView (integration)', () => { mediaSessionInstance.reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mediaSessionInstance.init('me'); - // eslint-disable-next-line jest/no-standalone-expect - expect(createdSessions).toHaveLength(1); // singleton-bleed guard + if (createdSessions.length !== 1) { + throw new Error(`Expected exactly one media session after init, got ${createdSessions.length}`); + } // Clear calls made during setup (reset() calls hideActionSheetRef internally) mockHideActionSheet.mockClear(); }); From 138e03d0942ca62d0f80c04c4f244003465904fa Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 14:26:11 -0300 Subject: [PATCH 04/12] fix(test): reference _T in optional param to satisfy no-unused-vars for type param --- app/containers/NewMediaCall/NewMediaCall.integration.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx index 514f8a6cafe..c8836b92c8b 100644 --- a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +++ b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx @@ -28,7 +28,7 @@ import type { InsideStackParamList } from '../../stacks/types'; // Compile-time guard — fails tsc if 'CallView' is removed from InsideStackParamList. // Function-call form avoids both no-void and no-unused-expressions lint rules. -const assertType = <_T extends true>() => undefined; +const assertType = <_T extends true>(_?: _T): void => {}; assertType(); // ─── Mocks ──────────────────────────────────────────────────────────────────── From 2fc54eb8a62e3e95d6149015436835592a497b20 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 14:33:59 -0300 Subject: [PATCH 05/12] =?UTF-8?q?test(NewMediaCall):=20close=20causal=20ch?= =?UTF-8?q?ain=20=E2=80=94=20startCall=20fires=20newCall=20in=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous tests severed the button→navigation pipeline: session.startCall was jest.fn() that did nothing, then tests manually invoked the newCall handler. Navigation and button press were causally unrelated. Key changes: - MockMediaSignalingSession now has a real handler registry (on() stores handlers; emit() dispatches to them) instead of jest.fn() that only records - startCall(actor, userId) fires 'newCall' synchronously with a synthetic outgoing call — simulating the SDK's response after WebRTC negotiation - fireEvent.press alone now causes Navigation.navigate('CallView') - Behavioral ended-handler test: fire call.emitter.emit('ended'), assert RNCallKeep.endCall and Navigation.back (replaces fragile toHaveLength counts) - Add hidden=true branch test: newCall must not navigate or populate store - Add callee-role branch test: incoming calls must not navigate on newCall - mockCallEmitter prefix required by jest.mock hoisting restriction --- .../NewMediaCall.integration.test.tsx | 301 +++++++++--------- 1 file changed, 153 insertions(+), 148 deletions(-) diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx index c8836b92c8b..2ace23de18f 100644 --- a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +++ b/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx @@ -1,19 +1,20 @@ // app/containers/NewMediaCall/NewMediaCall.integration.test.tsx -// Integration test: pressing the Call button in NewMediaCall triggers the -// media-signaling pipeline that opens CallView. Mocks @rocket.chat/media-signaling -// at the SDK boundary; does NOT mock mediaSessionInstance (that's the whole point). // -// Seam: the real MediaSessionInstance runs. When the SDK fires `newCall` -// (simulated here by invoking the registered handler), we verify -// - session.startCall was called with SDK-boundary args (actor, userId) -// - Navigation.navigate('CallView') was called -// - useCallStore.call was populated via the real setCall action -// - emitter.on was wired at both SDK-layer (MediaSessionInstance) and store-layer (useCallStore) -// Test 5 separately proves CallView renders when useCallStore.getState().setCall(call) runs. +// Integration test: CreateCall button press → MediaSessionInstance → CallView. +// +// Seam: @rocket.chat/media-signaling is mocked at the SDK boundary. +// The mock MediaSignalingSession simulates the real SDK's async behaviour: +// session.startCall(actor, userId) → fires 'newCall' with a synthetic call. +// This closes the causal chain — fireEvent.press alone causes Navigation.navigate. +// +// Real code running: MediaSessionInstance, useCallStore, NewMediaCall, CallView. +// Mocked at boundary: MediaSignalingSession (SDK), Navigation, RNCallKeep, +// DDP SDK, WebRTC, native device modules (not available in Jest). import React from 'react'; import { act, fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; +import RNCallKeep from 'react-native-callkeep'; import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { NewMediaCall } from './NewMediaCall'; @@ -27,16 +28,11 @@ import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptio import type { InsideStackParamList } from '../../stacks/types'; // Compile-time guard — fails tsc if 'CallView' is removed from InsideStackParamList. -// Function-call form avoids both no-void and no-unused-expressions lint rules. const assertType = <_T extends true>(_?: _T): void => {}; assertType(); // ─── Mocks ──────────────────────────────────────────────────────────────────── -// The jest moduleNameMapper covers app/lib/database only for the main repo root. -// Worktree paths (.claude/worktrees/quirky-euclid/app/lib/database) aren't matched, -// so we mock it per-file to prevent appGroup.ts from calling NativeModules.AppGroup at load. - let consoleErrorSpy: ReturnType | undefined; jest.mock('../../lib/database', () => ({ db: { get: jest.fn() }, @@ -45,16 +41,13 @@ jest.mock('../../lib/database', () => ({ jest.mock('../../lib/database/services/Subscription', () => ({ getDMSubscriptionByUsername: jest.fn().mockResolvedValue(null) })); - jest.mock('../../lib/methods/helpers/helpers', () => ({ getUidDirectMessage: jest.fn(() => 'other-user-id') })); - jest.mock('../../lib/navigation/appNavigation', () => ({ __esModule: true, default: { navigate: jest.fn(), back: jest.fn() } })); - jest.mock('../../lib/services/sdk', () => ({ __esModule: true, default: { @@ -62,7 +55,6 @@ jest.mock('../../lib/services/sdk', () => ({ methodCall: jest.fn() } })); - jest.mock('../../lib/store/auxStore', () => ({ store: { getState: jest.fn(() => ({ @@ -74,12 +66,10 @@ jest.mock('../../lib/store/auxStore', () => ({ subscribe: jest.fn(() => jest.fn()) } })); - jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn(), mediaDevices: { getUserMedia: jest.fn() } })); - jest.mock('react-native-callkeep', () => ({ __esModule: true, default: { @@ -88,12 +78,10 @@ jest.mock('react-native-callkeep', () => ({ setAvailable: jest.fn() } })); - jest.mock('../../lib/methods/helpers/fileDownload', () => ({ fileDownload: jest.fn(), fileDownloadAndPreview: jest.fn() })); - jest.mock('react-native-device-info', () => ({ __esModule: true, default: { @@ -115,59 +103,72 @@ jest.mock('react-native-device-info', () => ({ getSystemVersion: jest.fn(() => '14.0'), isTablet: jest.fn(() => false) })); - jest.mock('../../lib/native/NativeVoip', () => ({ __esModule: true, default: { stopNativeDDPClient: jest.fn() } })); - jest.mock('../../lib/methods/voipPhoneStatePermission', () => ({ requestPhoneStatePermission: jest.fn() })); - -// Required because useCallStore.ts imports it; the real hook calls AccessibilityInfo -// which is not globally mocked. useControlsVisible (used in CallButtons) reads it during render. jest.mock('../../lib/hooks/useIsScreenReaderEnabled', () => ({ useIsScreenReaderEnabled: jest.fn(() => false) })); - -// PeerList imports Avatar → database → appGroup (native NativeModule not available in Jest). -// FilterHeader and SelectedPeer may have similar chains. Mock them as null since they are -// not part of the integration contract (CreateCall button → MediaSessionInstance → CallView). +// PeerList/FilterHeader/SelectedPeer import Avatar → database → appGroup (native module). +// Not part of the integration contract (button → MediaSessionInstance → CallView). jest.mock('./PeerList', () => ({ PeerList: () => null })); jest.mock('./FilterHeader', () => ({ FilterHeader: () => null })); jest.mock('./SelectedPeer', () => ({ SelectedPeer: () => null })); - -// usePeerAutocompleteStore imports getPeerAutocompleteOptions → restApi → encryption -// → @rocket.chat/mobile-crypto (ESM, not transformable in Jest). Mock the leaf dependency -// so the real store module loads and state actions work normally. +// getPeerAutocompleteOptions → restApi → @rocket.chat/mobile-crypto (ESM, not transformable). jest.mock('../../lib/services/voip/getPeerAutocompleteOptions', () => ({ getPeerAutocompleteOptions: jest.fn().mockResolvedValue([]) })); - -// CallView/CallButtons imports navigateToCallRoom → goRoom → restApi → encryption → ESM fail. -// Same mock used in CallView/index.test.tsx. +// navigateToCallRoom → goRoom → restApi → encryption → ESM fail. jest.mock('../../lib/services/voip/navigateToCallRoom', () => ({ navigateToCallRoom: jest.fn().mockResolvedValue(undefined) })); const mockHideActionSheet = jest.fn(); -// Minimal mock — only hideActionSheetRef (used by CreateCall and useCallStore.reset) -// and showActionSheetRef (used by CallButtons in CallView) are needed. -// Avoid jest.requireActual: the real ActionSheet loads deviceInfo at module init which -// calls DeviceInfo.hasNotch() — a native method not available in Jest. jest.mock('../ActionSheet', () => ({ hideActionSheetRef: () => mockHideActionSheet(), showActionSheetRef: jest.fn() })); +// ─── Real emitter factory ───────────────────────────────────────────────────── +// Prefixed 'mock' so jest.mock() factories can reference it (jest hoisting rule). +// Used for call.emitter so handlers registered via on() actually run when emit() +// is called — lets tests assert downstream effects rather than call counts. + +function mockCallEmitter() { + const listeners: Record void)[]> = {}; + return { + on(event: string, handler: (...args: unknown[]) => void): void { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(handler); + }, + off(event: string, handler: (...args: unknown[]) => void): void { + if (listeners[event]) { + listeners[event] = listeners[event].filter(h => h !== handler); + } + }, + emit(event: string, ...args: unknown[]): void { + listeners[event]?.forEach(h => h(...args)); + } + }; +} + // ─── Media-signaling mock ───────────────────────────────────────────────────── +// +// Key design: on() maintains a real handler registry so that session.emit() +// dispatches to registered handlers. startCall(actor, userId) fires 'newCall' +// synchronously, simulating the SDK's response after WebRTC negotiation. +// session.emit() lets tests drive incoming-call and branch scenarios directly. type MockMediaSignalingSession = { userId: string; sessionId: string; endSession: jest.Mock; on: jest.Mock; + emit: (event: string, payload: unknown) => void; processSignal: jest.Mock; setIceGatheringTimeout: jest.Mock; startCall: jest.Mock; @@ -183,13 +184,42 @@ jest.mock('@rocket.chat/media-signaling', () => ({ MediaSignalingSession: jest .fn() .mockImplementation(function MockMediaSignalingSession(this: MockMediaSignalingSession, config: { userId: string }) { - const endSession = jest.fn(); + const handlers: Record void)[]> = {}; + this.userId = config.userId; - this.endSession = endSession; - this.on = jest.fn(); + this.endSession = jest.fn(); + + this.on = jest.fn().mockImplementation((event: string, handler: (payload: unknown) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }); + + // Allows tests to simulate incoming-call and branch scenarios. + this.emit = (event: string, payload: unknown) => { + handlers[event]?.forEach(h => h(payload)); + }; + this.processSignal = jest.fn().mockResolvedValue(undefined); this.setIceGatheringTimeout = jest.fn(); - this.startCall = jest.fn().mockResolvedValue(undefined); + + // Integration seam: startCall fires 'newCall' with a synthetic outgoing call, + // connecting fireEvent.press → startCall → newCall → setCall + navigate. + const self = this; + this.startCall = jest.fn().mockImplementation((_actor: string, userId: string) => { + const call: IClientMediaCall = { + callId: `call-${userId}`, + hidden: false, + state: 'ringing', + localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, + remoteParticipants: [{ local: false, role: 'callee', muted: false, held: false, contact: {} }], + reject: jest.fn(), + hangup: jest.fn(), + emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; + self.emit('newCall', { call }); + return Promise.resolve(); + }); + this.getCallData = jest.fn(); Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false }); createdSessions.push(this); @@ -198,64 +228,20 @@ jest.mock('@rocket.chat/media-signaling', () => ({ // ─── Helpers ────────────────────────────────────────────────────────────────── -function getNewCallHandler(): (payload: { call: IClientMediaCall }) => void { - const session = createdSessions[createdSessions.length - 1]; - if (!session) { - throw new Error('no session created'); - } - const entry = session.on.mock.calls.find(([name]: [string]) => name === 'newCall'); - if (!entry) { - throw new Error('newCall handler not registered'); - } - return entry[1] as (payload: { call: IClientMediaCall }) => void; -} - -function buildClientMediaCall(options: { - callId: string; - role: 'caller' | 'callee'; +function makeIncomingCall(options: { + callId?: string; + role?: 'caller' | 'callee'; hidden?: boolean; - contact?: { username?: string; sipExtension?: string }; }): IClientMediaCall { - const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; return { - callId: options.callId, + callId: options.callId ?? 'incoming-call', hidden: options.hidden ?? false, state: 'ringing', - localParticipant: { local: true, role: options.role, muted: false, held: false, contact: {} }, - remoteParticipants: [ - { - local: false, - role: options.role === 'caller' ? 'callee' : 'caller', - muted: false, - held: false, - contact: options.contact ?? {} - } - ], - reject: jest.fn(), - emitter: emitter as unknown as IClientMediaCall['emitter'] - } as unknown as IClientMediaCall; -} - -// For Test 5 — must satisfy IClientMediaCall shape so useCallStore.setCall can wire it. -function createMockCall(overrides: { callId?: string } = {}): IClientMediaCall { - const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; - return { - callId: overrides.callId ?? 'mock-call-id', - state: 'active', - hidden: false, - localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, - remoteParticipants: [ - { - local: false, - role: 'callee', - muted: false, - held: false, - contact: { displayName: 'Bob', username: 'bob', sipExtension: '' } - } - ], + localParticipant: { local: true, role: options.role ?? 'callee', muted: false, held: false, contact: {} }, + remoteParticipants: [{ local: false, role: options.role === 'caller' ? 'callee' : 'caller', muted: false, held: false, contact: {} }], reject: jest.fn(), hangup: jest.fn(), - emitter: emitter as unknown as IClientMediaCall['emitter'] + emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] } as unknown as IClientMediaCall; } @@ -279,7 +265,6 @@ describe('NewMediaCall → CallView (integration)', () => { if (createdSessions.length !== 1) { throw new Error(`Expected exactly one media session after init, got ${createdSessions.length}`); } - // Clear calls made during setup (reset() calls hideActionSheetRef internally) mockHideActionSheet.mockClear(); }); @@ -289,7 +274,9 @@ describe('NewMediaCall → CallView (integration)', () => { mediaSessionInstance.reset(); }); - it('user peer: press Call → navigates to CallView, binds call, wires emitter at both layers', () => { + // ── Outgoing calls (button press path) ─────────────────────────────────── + + it('user peer: press Call → startCall fires newCall → navigates to CallView', () => { setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' }); const session = createdSessions[createdSessions.length - 1]; @@ -299,38 +286,29 @@ describe('NewMediaCall → CallView (integration)', () => { ); + // The entire pipeline runs from this single press: + // CreateCall.handleCall → mediaSessionInstance.startCall('user-1', 'user') + // → session.startCall('user', 'user-1') [args reversed for SDK] + // → mock fires 'newCall' → MediaSessionInstance handler + // → useCallStore.setCall + Navigation.navigate('CallView') fireEvent.press(getByTestId('new-media-call-button')); - const outgoing = buildClientMediaCall({ callId: 'c1', role: 'caller' }); - getNewCallHandler()({ call: outgoing }); - - // SDK-boundary contract: session.startCall(actor, userId) — note reversed vs public API - expect(session.startCall).toHaveBeenCalledTimes(1); + // SDK-boundary: args are (actor, userId) — reversed from the public API expect(session.startCall).toHaveBeenCalledWith('user', 'user-1'); - - expect(Navigation.navigate).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + expect(mockHideActionSheet).toHaveBeenCalledTimes(1); - // Real setCall action ran — store is populated with the actual call object - expect(useCallStore.getState().call).toBe(outgoing); - - // Emitter wiring — two layers. - // outgoing.emitter.on is jest.fn() at runtime; cast to access .mock.calls. - const emitterOn = outgoing.emitter.on as unknown as jest.Mock; + const call = useCallStore.getState().call; + expect(call?.callId).toBe('call-user-1'); - // 'stateChange': once from MediaSessionInstance.ts (SDK logging) + once from useCallStore.ts (store wiring) - const stateChangeCalls = emitterOn.mock.calls.filter(([name]: [string]) => name === 'stateChange'); - expect(stateChangeCalls).toHaveLength(2); - - // 'trackStateChange': once from useCallStore.ts - expect(emitterOn).toHaveBeenCalledWith('trackStateChange', expect.any(Function)); - - // 'ended': once from MediaSessionInstance.ts (RNCallKeep cleanup) + once from useCallStore.ts (Navigation.back) - const endedCalls = emitterOn.mock.calls.filter(([name]: [string]) => name === 'ended'); - expect(endedCalls).toHaveLength(2); + // Behavioral: firing 'ended' triggers RNCallKeep cleanup and navigation back. + // Real emitter dispatches to both handlers wired by MediaSessionInstance and useCallStore. + (call!.emitter as unknown as ReturnType).emit('ended'); + expect((RNCallKeep.endCall as jest.Mock)).toHaveBeenCalledWith('call-user-1'); + expect(Navigation.back).toHaveBeenCalled(); }); - it('SIP peer: press Call → navigates to CallView and binds the call', () => { + it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', () => { setSelectedPeer({ type: 'sip', value: '+5511999999999', label: '+55 11 99999-9999' }); const session = createdSessions[createdSessions.length - 1]; @@ -342,21 +320,12 @@ describe('NewMediaCall → CallView (integration)', () => { fireEvent.press(getByTestId('new-media-call-button')); - const outgoing = buildClientMediaCall({ callId: 'c2', role: 'caller', contact: { sipExtension: 'ext' } }); - getNewCallHandler()({ call: outgoing }); - - // SDK-boundary contract: session.startCall(actor, userId) - expect(session.startCall).toHaveBeenCalledTimes(1); expect(session.startCall).toHaveBeenCalledWith('sip', '+5511999999999'); - - expect(Navigation.navigate).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); - - expect(useCallStore.getState().call?.callId).toBe('c2'); + expect(useCallStore.getState().call?.callId).toBe('call-+5511999999999'); }); - it('no peer selected: button disabled, no navigate, action sheet stays open', () => { - // do not set a peer — store starts with selectedPeer: null + it('no peer selected: button disabled, startCall not called, action sheet stays open', () => { const session = createdSessions[createdSessions.length - 1]; const { getByTestId } = render( @@ -366,29 +335,66 @@ describe('NewMediaCall → CallView (integration)', () => { ); fireEvent.press(getByTestId('new-media-call-button')); - // do NOT drive newCall expect(getByTestId('new-media-call-button').props.accessibilityState?.disabled).toBe(true); expect(session.startCall).not.toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(useCallStore.getState().call).toBeNull(); - // Only negative-path assertion: positive paths are covered by CreateCall.test.tsx expect(mockHideActionSheet).not.toHaveBeenCalled(); }); - // Test 4 is a module-scope compile-time assertion (see _routeCheck above) — no it(...) block. - // Renaming 'CallView' in InsideStack.tsx or MasterDetailStack/index.tsx will break tsc here. + // ── newCall handler branches (incoming / SDK-driven path) ───────────────── + // These scenarios are triggered by the DDP listener in MediaSessionInstance, + // not by button press. We drive them via session.emit('newCall', ...) which + // exercises the same registered handler as the real DDP path. + + it('hidden call: newCall with hidden=true does not navigate or populate store', () => { + const session = createdSessions[createdSessions.length - 1]; + const hiddenCall = makeIncomingCall({ callId: 'hidden-1', hidden: true, role: 'caller' }); - it('setCall(call) populates store and CallView renders its container', () => { - const call = createMockCall({ callId: 'c5' }); + session.emit('newCall', { call: hiddenCall }); - // Use the real action — exercises emitter subscriptions, InCallManager.start, contact extraction. - // This proves the call-site (MediaSessionInstance calling setCall) produces state CallView renders, - // not merely that an arbitrary state shape renders. - useCallStore.getState().setCall(call); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(useCallStore.getState().call).toBeNull(); + }); + + it('callee role: newCall does not navigate (incoming calls route via answerCall)', () => { + const session = createdSessions[createdSessions.length - 1]; + const incomingCall = makeIncomingCall({ callId: 'incoming-1', role: 'callee' }); + + session.emit('newCall', { call: incomingCall }); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(useCallStore.getState().call).toBeNull(); + }); - // Proves real setCall ran (not a setState bypass) - expect(call.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + // ── CallView render contract ────────────────────────────────────────────── + // Verifies useCallStore.setCall produces state that CallView renders. + // Kept here because it proves the store → UI contract that the outgoing-call + // path relies on (MediaSessionInstance calls setCall before navigating). + + it('setCall populates store and CallView renders; clearing store unmounts it', () => { + const emitter = mockCallEmitter(); + const call = { + callId: 'c-render', + state: 'active', + hidden: false, + localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, + remoteParticipants: [ + { + local: false, + role: 'callee', + muted: false, + held: false, + contact: { displayName: 'Bob', username: 'bob', sipExtension: '' } + } + ], + reject: jest.fn(), + hangup: jest.fn(), + emitter: emitter as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; + + useCallStore.getState().setCall(call); const { getByTestId, queryByTestId } = render( @@ -398,7 +404,6 @@ describe('NewMediaCall → CallView (integration)', () => { expect(getByTestId('call-view-container')).toBeTruthy(); - // Teardown — setState acceptable here because we're just clearing the UI act(() => { useCallStore.setState({ call: null }); }); From 19f3ef50f6eb9a7fba96d070c24db73c1e2f68ee Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 15:08:06 -0300 Subject: [PATCH 06/12] docs(plans): add voip integration tests phase 2 plan --- docs/plans/voip-integration-tests-phase-2.md | 297 +++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 docs/plans/voip-integration-tests-phase-2.md diff --git a/docs/plans/voip-integration-tests-phase-2.md b/docs/plans/voip-integration-tests-phase-2.md new file mode 100644 index 00000000000..eca9e0f51c5 --- /dev/null +++ b/docs/plans/voip-integration-tests-phase-2.md @@ -0,0 +1,297 @@ +# VoIP Integration Tests — Phase 2 + +**File renamed + extended:** `app/containers/NewMediaCall/NewMediaCall.integration.test.tsx` → `app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx` (see §1 for rationale; CI-glob audit below shows no references to the old filename). +**Scope:** Add 3 integration paths (incoming answerCall, hang up, mute/hold) and fix two hygiene issues (blanket `console.error` suppression, missing `act()` wrappers). **startCall rejection is deferred to Phase 3** — see ADR Follow-up #1. +**Non-goals:** No production-code changes. No unit-level store tests that bypass real handlers. No rejection-path test until the unhandled-promise fix lands. + +**CI-glob audit:** `grep testMatch|testRegex|integration\.test|NewMediaCall` across `jest.config*`, `package.json`, `.github/**/*.{yml,yaml}` returns **no matches** referencing the old filename. Jest default `*.test.tsx` picks up the renamed file automatically. Safe to rename. + +--- + +## RALPLAN-DR Summary + +### Principles +1. **Exercise real handlers, not call counts.** Every test must travel through real `MediaSessionInstance` / `useCallStore` code. If an assertion can be satisfied by stubbing the method under test, it is the wrong assertion. +2. **Mock only at the SDK boundary.** `@rocket.chat/media-signaling`, `RNCallKeep`, `InCallManager`, `Navigation`, native modules — nothing internal. +3. **No blanket `console.error` suppression.** Suppression belongs per-test with `toHaveBeenCalledWith(...)` assertions, so `act()` warnings and real bugs surface. +4. **Wrap every emitter-driven state mutation in `act()`.** `emitter.emit(...)` fires synchronous React `set` calls through Zustand subscribers; these must be inside `act`. +5. **Minimum new surface area.** Reuse `mockCallEmitter`, `makeIncomingCall`, the existing `MockMediaSignalingSession`. Extend — don't duplicate. + +### Decision Drivers (top 3) +1. **Coverage gap severity.** answerCall + endCall + mute/hold are the call lifecycle's core; currently zero integration coverage. +2. **Mock surface cost.** Adding mocks for `InCallManager`, `getCallData`, `accept`, `setMuted`, `setHeld`, `localParticipant.setMuted` increases maintenance weight; minimize via shared helpers. +3. **Test reliability.** Async `answerCall` + React effects + Zustand subscriptions are prone to `act()` warnings and flakes — we must handle them, not hide them. + +### Viable Options + +#### Option A — All paths in existing file, keep current filename +- **Pros:** Zero file-structure churn; reuses beforeEach scaffolding; fastest to land; diff stays local. +- **Cons:** Filename `NewMediaCall.integration.test.tsx` becomes a lie — it mostly tests `MediaSessionInstance` + `useCallStore`, not the `NewMediaCall` component. Name/content mismatch erodes file-tree navigability. File grows from ~412 to ~700+ LOC. + +#### Option A′ — Rename to `VoipCallLifecycle.integration.test.tsx`, single-file structure **[SELECTED]** +- **Pros:** Filename matches intent (the call lifecycle: outgoing press, incoming answer, hangup, controls); no mock duplication; same single-file benefits as A; CI-glob audit shows no references to old name so rename is a pure `git mv` + one `jest.mock` path recheck. +- **Cons:** One extra commit for the rename; git history for the file becomes slightly harder to blame pre-rename (mitigated by `git log --follow`). + +#### Option B — Split into sibling files +- `VoipCallLifecycle.outgoing.integration.test.tsx`, `.answerCall.integration.test.tsx`, `.endCall.integration.test.tsx`, `.controls.integration.test.tsx` +- **Pros:** Each file <250 LOC; focused failures; parallel Jest shard friendly. +- **Cons:** 4× duplicated `jest.mock(...)` boilerplate (~130 LOC of mocks per file); drift risk between files; onboarding cost. + +#### Option C — Shared helper module + split +- Extract `app/containers/NewMediaCall/__integration__/setup.ts` exporting mock factories and lifecycle hooks, then split like Option B. +- **Pros:** Clearest separation; one source of mock truth; each test file reads as intent-only. +- **Cons:** Upfront refactor of the existing passing suite (regression risk on 6 green tests); helpers with side effects (jest.mock hoisting) are subtle; Jest factory hoisting makes extracting `jest.mock` calls non-trivial (must stay in test files). + +### Selected: **Option A′** — rename to `VoipCallLifecycle.integration.test.tsx`, single-file structure with inline helpers + +**Why:** Filename honesty matters. The current `NewMediaCall.integration.test.tsx` already spans three owners (`NewMediaCall` component press path, `MediaSessionInstance` handlers, `useCallStore` actions) and the new tests double down on the latter two. Renaming to `VoipCallLifecycle.integration.test.tsx` makes the file's genre clear; CI-glob audit confirms no config references the old name. Jest's `jest.mock` hoisting rule forbids cleanly extracting mock factories into sibling modules — Option C's headline benefit is not achievable. Option B's mock-duplication cost is real and invites drift across 4 files covering tightly-coupled code paths. Option A′ keeps the 6 existing green tests untouched (except the rename), leverages the existing `MockMediaSignalingSession` infrastructure, and bounded file growth (~250 net new LOC for 9 new tests; Phase 3 would add ~40 more) is acceptable for cohesive call-lifecycle coverage. We will add small inline helpers (`makeSyntheticCall`, `emitDDPMediaSignal`) inside the test file to keep intent readable without cross-file helpers. + +File-size guidance is **advisory, not enforceable**: if the file grows past ~800 LOC after Phase 3, consider splitting via Option B. Growth alone does not block landing. + +--- + +## Plan Body + +### 1. File-Structure Decision +**Rename then extend.** Step 1: `git mv app/containers/NewMediaCall/NewMediaCall.integration.test.tsx app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx`. Step 2: update the top-level `describe` label to `'VoIP call lifecycle (integration)'`. Step 3: add new tests as nested `describe` blocks with genre labels so future file-tree scans know what each block owns: +- `describe('MediaSessionInstance contract: answerCall', …)` +- `describe('UI store contract: Hang up', …)` +- `describe('UI store contract: In-call controls (mute/hold)', …)` + +Each `describe` gets its own inner `beforeEach` only if it needs extra setup beyond the outer one. + +**CI verification (performed):** `grep -r 'NewMediaCall\.integration'` returned only the test file itself — no Jest config, no package.json script, no GitHub Actions workflow references the old filename. Jest's default `testMatch` picks up `**/*.test.tsx`, so the rename is pure. + +### 2. Shared In-File Helpers (add near existing `makeIncomingCall`) + +#### `makeSyntheticCall(overrides)` — extends `makeIncomingCall` +Returns an `IClientMediaCall` with extra jest.fn methods that real code paths exercise: +- `accept: jest.fn().mockResolvedValue(undefined)` — needed by `answerCall` +- `hangup: jest.fn()`, `reject: jest.fn()` — already present +- `localParticipant.setMuted: jest.fn()` +- `localParticipant.setHeld: jest.fn()` +- `sendDTMF: jest.fn()` (defensive; `setDialpadValue` uses it but not in these tests) +- `state: 'ringing' | 'active'` configurable (drives ringing→reject branch in instance endCall) +- `emitter: mockCallEmitter()` — real emit-dispatch so `trackStateChange` reaches store subscribers + +#### `emitDDPMediaSignal(session, signal)` helper +Drives the same payload shape `MediaSessionInstance.mediaSignalListener` expects. Because `sdk.onStreamData` is mocked to return `{ stop: jest.fn() }` with no callback capture, we need to capture the handler. **Modify the existing `sdk` mock** to capture the callback: +```ts +let capturedStreamHandler: ((msg: IDDPMessage) => void) | null = null; +jest.mock('../../lib/services/sdk', () => ({ + default: { + onStreamData: jest.fn((_name, handler) => { + capturedStreamHandler = handler; + return { stop: jest.fn() }; + }), + methodCall: jest.fn() + } +})); +``` +Then `emitDDPMediaSignal(signal)` calls `capturedStreamHandler({ fields: { eventName: `${userId}/media-signal`, args: [signal] } })`. This is the **one** non-trivial mock extension required. + +**Explicit reset in `beforeEach`:** capturing into a module-level variable creates test-pollution risk across suites. The outer `beforeEach` must reset it: `capturedStreamHandler = null;` before `mediaSessionInstance.init('me')` runs (which re-registers the handler). Without this reset a test that forgets to re-init could accidentally invoke a stale handler from a previous test's session. + +#### DDP signal schema — verified against SDK types + +The exact gate in `app/lib/services/voip/MediaSessionInstance.ts:75-85` is: + +```ts +if ( + signal.type === 'notification' && + signal.notification === 'accepted' && + signal.signedContractId === getUniqueIdSync() && + nativeAcceptedCallId === signal.callId && + call == null +) { + this.answerCall(signal.callId).catch(...); +} +``` + +Cross-checked against `@rocket.chat/media-signaling/dist/lib/Session.js:139-143`: the SDK itself uses `signal.type === 'notification'` + `signal.signedContractId` + `signal.notification === 'accepted'` as the wire contract — **not** `'contractNotification'`. The string `contractNotification` does not appear in the SDK's type definitions (searched `node_modules/@rocket.chat/media-signaling/dist/**/*.d.ts` — no hits). Tests must use the exact object: + +```ts +{ type: 'notification', notification: 'accepted', signedContractId: 'test-device-id', callId: '' } +``` + +`getUniqueIdSync` is mocked to return `'test-device-id'`, so `signedContractId` must equal that literal. + +### 3. New Top-Level Mocks Required + +- **`react-native-incall-manager`** — `useCallStore.setCall` calls `InCallManager.start`; `reset` calls `InCallManager.stop`. Add: + ```ts + jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn().mockResolvedValue(undefined) } + })); + ``` + Without it, `setCall` currently logs `InCallManager.start failed` via the already-suppressed `console.error` — another reason to remove blanket suppression. + +- **`sdk.onStreamData` callback capture** — see §2. + +No other top-level mock additions. + +### 4. Tests to Add + +> **Assertion philosophy (applies to all blocks below):** Per Principle #1, we assert against the *real integration seams* — boundary mocks (`RNCallKeep.*`, `Navigation.*`, `InCallManager.*`) and observable store state (`useCallStore.getState().X`). We avoid redundant `toHaveBeenCalled` assertions on inner method calls (e.g., `call.hangup`, `localParticipant.setMuted`) when the resulting store-state assertion already proves the real handler ran. Call-count assertions are kept **only** for boundary mocks, since those are the genuine observable seams with the outside world. + +#### 4a. MediaSessionInstance contract: answerCall + +**Test A1 — accepted signal + native pre-accept → answerCall navigates** +- Setup: after outer `beforeEach`, `useCallStore.getState().setNativeAcceptedCallId('incoming-1')`, mock `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'incoming-1' }))`. +- Drive: `await act(async () => { emitDDPMediaSignal({ type: 'notification', notification: 'accepted', signedContractId: 'test-device-id', callId: 'incoming-1' }); });` — `answerCall` is async, must flush microtasks. +- Assert (boundary + state): `RNCallKeep.setCurrentCallActive('incoming-1')` called; `Navigation.navigate('CallView')` called; `useCallStore.getState().call?.callId === 'incoming-1'`. (No redundant `mainCall.accept` call-count — if `call` is in the store, `accept` ran.) + +**Test A2 — call not found branch** +- Setup: `setNativeAcceptedCallId('missing-1')`, `session.getCallData.mockReturnValue(undefined)`. +- Drive: same DDP signal shape with `callId: 'missing-1'`. +- Assert: `RNCallKeep.endCall('missing-1')` called; `useCallStore.getState().nativeAcceptedCallId === null`; `Navigation.navigate` NOT called; `useCallStore.getState().call === null`. + +**Test A3 — idempotency branch (existing call matches)** +- **Test-pollution guard (first line):** `expect(useCallStore.getState().nativeAcceptedCallId).toBe(null);` — fails fast if a prior test leaked state despite the outer `reset()`. +- Setup: pre-populate store via `act(() => useCallStore.getState().setCall(existingCall))` where `existingCall.callId === 'incoming-1'`; then `(Navigation.navigate as jest.Mock).mockClear();` to ignore the setup's navigate. +- Drive: `await mediaSessionInstance.answerCall('incoming-1')` directly (the DDP gate checks `call == null`, which we cannot satisfy here, so we test the public method directly — this is still integration because we assert real guard logic, and A1 already covers the DDP entry). +- Assert (boundary only): `Navigation.navigate` NOT called; `RNCallKeep.setCurrentCallActive` NOT called; `(session.getCallData as jest.Mock).toHaveBeenCalledTimes(0)` — this **is** a valid call-count assertion because `getCallData` is the SDK boundary, confirming the early-return happened before any SDK interaction. + +**Mock extensions needed:** `session.getCallData` per-test `mockReturnValue`. Synthetic call needs `accept: jest.fn().mockResolvedValue(undefined)`. + +#### 4b. UI store contract: Hang up + +> **Important clarification:** The CallView end button wires to `useCallStore.endCall` (confirmed at `app/views/CallView/components/CallButtons.tsx:44,65`), NOT `MediaSessionInstance.endCall`. The latter is called from native CallKit "end" events and other entry points. Both need coverage. + +**Test B1 — UI-triggered `useCallStore.endCall`** +- Setup: complete outgoing flow via existing press path so `setCall` binds listeners and populates the store. +- Drive: `act(() => { useCallStore.getState().endCall(); });` +- Assert (boundary + state): `RNCallKeep.endCall('call-user-1')` called; `InCallManager.stop` called (via `reset`); `useCallStore.getState().call === null`; `useCallStore.getState().callId === null`. (No redundant `call.hangup` call-count — store `call === null` proves `endCall` ran through `reset`.) + +**Test B2 — `MediaSessionInstance.endCall` during active state → hangup** +- Setup: `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'active-1', state: 'active' }))`. +- Drive: `act(() => { mediaSessionInstance.endCall('active-1'); });` +- Assert (boundary + state): `RNCallKeep.endCall('active-1')`; `RNCallKeep.setCurrentCallActive('')`; `RNCallKeep.setAvailable(true)`; `useCallStore.getState().call === null`. (No redundant `mainCall.hangup`/`mainCall.reject` counts — the branch is an internal implementation detail; what matters is the store reset and RNCallKeep cleanup.) + +**Test B3 — `MediaSessionInstance.endCall` during ringing → reject branch** +- Setup: `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'ringing-1', state: 'ringing' }))`. +- Drive: `act(() => { mediaSessionInstance.endCall('ringing-1'); });` +- Assert (boundary + state): `RNCallKeep.endCall('ringing-1')`; `useCallStore.getState().call === null`. (Branch differentiation between ringing→reject vs active→hangup is covered by the SDK's own tests; our integration value is that the RNCallKeep/store cleanup runs regardless of branch.) + +**Mock extensions needed:** none new beyond §2 helpers. + +#### 4c. UI store contract: In-call controls (mute/hold) + +**Test C1 — `toggleMute` → store `isMuted` flips** +- Setup: complete outgoing flow so `setCall` has wired listeners. Pre-assertion: `useCallStore.getState().isMuted === false`. +- Drive: `act(() => { useCallStore.getState().toggleMute(); });` +- Assert (state): `useCallStore.getState().isMuted === true`. +- Second press: `act(() => useCallStore.getState().toggleMute())` → `isMuted === false`. (No call-count on `localParticipant.setMuted` — store state transition proves the action ran the real handler, and `setMuted` is an internal SDK method not a boundary.) + +**Test C2 — `toggleHold` → store `isOnHold` flips** +- Same pattern; `isOnHold` goes `false → true → false`. No call-count on `setHeld`. + +**Test C3 — `trackStateChange` emission syncs store from call** +- Setup: outgoing flow. Mutate synthetic call fields to simulate SDK side: `call.localParticipant.muted = true; call.remoteParticipants[0].held = true;` +- Drive: `act(() => { (call.emitter as unknown as ReturnType).emit('trackStateChange'); });` +- Assert (state): `useCallStore.getState().isMuted === true`; `useCallStore.getState().remoteHeld === true`; `useCallStore.getState().controlsVisible === true`. + +**Mock extensions needed:** `localParticipant.setMuted`, `localParticipant.setHeld` as `jest.fn()` on `makeSyntheticCall` (still required — the real `toggleMute`/`toggleHold` calls them and would throw without a stub, even though we don't assert on the call). + +#### 4d. startCall rejection path — **DEFERRED to Phase 3** + +This test is intentionally removed from Phase 2. See ADR Follow-up #1 for rationale: the production code at `MediaSessionInstance.ts:151-155` does not `await` or `.catch` the SDK's `startCall` promise, so a rejection leaks as an unhandled promise. Writing a test for this behavior would either (a) force us to accept unhandled-rejection noise in Jest output, contradicting the Success Criterion "no unhandled promise rejections", or (b) require masking the rejection in a way that the production code does not. The correct sequencing is: **Phase 3 lands a `.catch` in production code, then writes the integration test against the fixed behavior.** Doing the test before the fix would bake today's bug into the regression suite. + +### 5. `consoleErrorSpy` Fix — Spike First, Then Decide Per-Warning + +**Do not blindly delete the blanket spy.** Instead, treat this as a time-boxed spike: + +**Step 5a — Spike:** +1. Remove the blanket `jest.spyOn(console, 'error').mockImplementation(() => {})` from outer `beforeEach` and its `afterEach` restore. +2. Run the existing 6 tests: `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'`. +3. Capture every `console.error` and every React `act()` warning that surfaces. Produce a table: `| warning text (first 80 chars) | source | count |`. + +**Step 5b — Classify each warning and apply the matching remedy:** +- **(a) Fix at the root** — if the warning points to a real `act()` gap (our code), wrap the offending call and confirm the warning disappears. This is the preferred path. +- **(b) Narrow per-test spy with exact-string matcher** — if the warning is deterministic and tied to a specific test path (e.g., `MediaSessionInstance` logs `[VoIP] Error resolving room id from contact` when `navigateToCallRoom` is unavailable), use `jest.spyOn(console, 'error').mockImplementation((msg: string) => { if (!msg.includes('')) { throw new Error('unexpected console.error: ' + msg); } });` scoped to just that test. The `throw on mismatch` pattern is the key — it preserves the spy's safety net. +- **(c) Narrow-scoped known library noise** — *escape clause:* if the spike reveals warnings from 3rd-party code we cannot fix at source (e.g., `@expo/vector-icons` font-loading warning, React Native logbox noise), an exact-string `includes(…)` suppression in the outer `beforeEach` is acceptable **provided each suppressed substring is explicitly commented with the library source and why it's unfixable**. This is a permitted compromise, not a blanket fallback. Prefer (a) then (b); reach for (c) only when neither applies. + +**Step 5c — Re-run until green.** The non-option is reinstating a blanket `() => {}` suppressor with no substring matcher. Everything else (fix, narrow spy, or commented substring allowlist) is on the table. + +### 6. `act()` Wrapper Audit + +Every synchronous or async React-state-mutating call inside a test must be wrapped. + +**Existing tests (line references from current file):** +- Line 306: `(call!.emitter …).emit('ended')` — **MISSING `act()`.** Add: `act(() => { (call!.emitter …).emit('ended'); });` — the emitter triggers `handleEnded` → `get().reset()` → `setState` + `Navigation.back`, all synchronous React-reachable state. +- Line 355: `session.emit('newCall', { call: hiddenCall })` — the handler early-returns for hidden calls, so no state mutation occurs. Still safer to wrap in `act()` for future-proofing; mandatory if the early-return is removed. +- Line 365: `session.emit('newCall', { call: incomingCall })` — the handler runs but `role === 'callee'` skips `setCall`/navigate. `call.emitter.on('ended', …)` is wired though (line 105-107 of MediaSessionInstance), which does not mutate React state but does register a listener. `act()` wrap is not strictly needed but recommended for consistency. +- Line 397: `useCallStore.getState().setCall(call)` — **MISSING `act()`.** `setCall` mutates Zustand state that the rendered CallView subscribes to. Wrap. +- Line 407: `useCallStore.setState({ call: null })` — already wrapped in `act()`, good. + +**New tests — act() required at every listed emit/setState point:** +- A1: `await act(async () => { emitDDPMediaSignal(...); });` +- A3: `act(() => useCallStore.getState().setCall(existingCall))` in setup. +- B1/B2/B3: `act(() => { useCallStore.getState().endCall(); });` / `act(() => { mediaSessionInstance.endCall(...); });` +- C1/C2: `act(() => { useCallStore.getState().toggleMute(); });` +- C3: `act(() => { (emitter as any).emit('trackStateChange'); });` + +### 7. Execution & Verification + +Plan step → verification: + +1. **Rename file:** `git mv NewMediaCall.integration.test.tsx VoipCallLifecycle.integration.test.tsx`; update top-level `describe` label; verify existing 6 tests still pass: `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'`. +2. Add `InCallManager` mock + `sdk.onStreamData` callback capture + `capturedStreamHandler` reset in `beforeEach` → rerun; 6 tests still green. +3. Fix existing `act()` gaps (lines 306, 397 of pre-rename file) → rerun; 6 green. +4. **§5a spike:** remove blanket `consoleErrorSpy`, rerun, catalog surfaced warnings. +5. **§5b classification:** apply remedies (a/b/c) per warning; rerun until 6 tests green with no warning noise in output. Decision log each remedy in the test file as a comment near the spy. +6. Add `makeSyntheticCall` + `emitDDPMediaSignal` helpers → compile check. +7. **Mid-plan lint check:** `yarn lint` clean on `app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx` — do not proceed if lint is red. +8. Add §4a tests (3: A1, A2, A3) → green. Confirm A3's test-pollution guard passes. +9. Add §4b tests (3: B1, B2, B3) → green. +10. Add §4c tests (3: C1, C2, C3) → green. +11. **Final:** `yarn lint` clean; `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'` shows **15 tests** total (6 existing + 9 new); full suite `TZ=UTC yarn test` green; no unhandled rejection warnings in Jest output; no stray `act()` warnings. + +Phase 3 (separate PR): land `.catch` on `MediaSessionInstance.ts:154`, then add the startCall-rejection integration test (→ 16 tests). + +--- + +## ADR — Integration Test Expansion for VoIP Call Lifecycle + +**Decision:** Rename the existing test file to `VoipCallLifecycle.integration.test.tsx` and extend it with **9 new tests** (A1-A3 answerCall, B1-B3 hang-up, C1-C3 controls). Replace blanket `console.error` suppression with a spike-driven per-warning remediation. Audit all `act()` wrappers. **Defer the startCall-rejection test to Phase 3** so the production `.catch` lands first. + +**Drivers:** +1. Zero integration coverage on answerCall/endCall/mute/hold despite these being the call lifecycle core. +2. The blanket `console.error` spy currently hides `act()` warnings and makes flakes likely. +3. Existing `MockMediaSignalingSession` + `mockCallEmitter` infrastructure already handles real handler dispatch — incremental tests cost far less than alternative designs. + +**Alternatives Considered:** +- **Keep filename `NewMediaCall.integration.test.tsx` (Option A).** Rejected: name/content mismatch worsens as we add `MediaSessionInstance`- and `useCallStore`-centric tests. +- **Split into 4 sibling files (Option B).** Rejected due to ~130 LOC of mock duplication per file, drift risk, and Jest mock-hoisting constraints preventing clean helper extraction. +- **Shared helper module (Option C).** Rejected because `jest.mock` factories cannot be cleanly extracted without losing hoisting semantics; benefit does not justify complexity. +- **Keeping `consoleErrorSpy` blanket suppression.** Rejected — it hides `act()` warnings, which are strong signals of real async/React bugs. The §5 spike replaces it with per-warning treatment. +- **Writing startCall-rejection test in Phase 2.** Rejected because the production code leaks an unhandled promise; the test would either bake the bug into the regression suite or contradict the no-unhandled-rejection success criterion. +- **Testing via React Testing Library + real CallView buttons instead of direct store calls.** Rejected for mute/hold/endCall because the CallView button tests (`app/views/CallView/index.test.tsx:253-265`) already exist and mock store actions. The new value here is proving the *store action* runs real `IClientMediaCall` methods — direct `useCallStore.getState().X()` is more precise for that contract. Outgoing-call path already proves the button→store wiring. + +**Why Chosen:** Option A′ (rename + single-file) preserves 6 green tests with a trivial `git mv`, produces an honest filename, leverages existing infrastructure, minimizes diff size, and lets us surface (not hide) async warnings. All new coverage travels through real `MediaSessionInstance` and `useCallStore` handlers with assertions on real boundary seams only (Principle #1). Deferring startCall rejection to Phase 3 sequences the code fix before the test that depends on it. + +**Consequences:** +- File grows from ~412 to ~670 LOC (9 new tests). Acceptable given cohesion. The ~800 LOC reference is **advisory, not enforceable** — do not block on it. +- Adding `InCallManager` mock means future tests that exercise speaker toggle get setup for free. +- Capturing the `sdk.onStreamData` callback makes DDP-driven tests trivial for future signal types. +- Removing blanket `console.error` suppression may expose latent issues in the existing 6 tests on first run — the §5 spike budgets this. +- The rename creates one `git log --follow`-ish hurdle for pre-rename blame; acceptable. + +**Follow-ups (owners + phase tags):** +1. **[Phase 3 — owner: @voip-team]** Land `.catch` (or convert to `async`) on `MediaSessionInstance.ts:151-155`'s `this.instance?.startCall(actor, userId)`. Then add integration test D1 (rejected `session.startCall` → no navigate, no `setCall`, no unhandled-rejection in Jest output). +2. **[Phase 3 — owner: @voip-team]** Fix the `useCallStore.getState().setCall(call)` outside `act()` at what was line 397 of the original file. Confirm the test's intent remains (`setCall` populates store → `CallView` renders → `setState({ call: null })` unmounts). If behavior changes after `act()` is added, reconsider the test's shape. +3. **[Advisory]** If the file exceeds ~800 LOC after Phase 3, split into Option B shape. Mechanical copy of `describe` blocks and mock boilerplate. Not a blocker. +4. **[Advisory]** Extract `mockCallEmitter` and `makeSyntheticCall` to a test-helpers module only if a second integration test file needs them. Premature today. + +--- + +## Success Criteria + +- File renamed to `VoipCallLifecycle.integration.test.tsx`; `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'` picks it up. +- **9 new tests added** (A1-A3, B1-B3, C1-C3); existing 6 tests still pass; **15 tests total in Phase 2**. +- Phase 3 (separate PR, owner @voip-team) adds the 16th test after the `.catch` fix. +- No `act()` warnings in Jest output (verified post-spike per §5). +- No unhandled promise rejections in Jest output. +- `yarn lint` clean on the renamed file (mid-plan check at step 7.7 and final at step 7.11). +- ADR follow-ups #1 and #2 filed as Phase 3 tasks with @voip-team owner. From 28ec7569f408469b66fbc7bd35246c8df8ce1f1d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 15:54:43 -0300 Subject: [PATCH 07/12] Revert "docs(plans): add voip integration tests phase 2 plan" This reverts commit 19f3ef50f6eb9a7fba96d070c24db73c1e2f68ee. --- docs/plans/voip-integration-tests-phase-2.md | 297 ------------------- 1 file changed, 297 deletions(-) delete mode 100644 docs/plans/voip-integration-tests-phase-2.md diff --git a/docs/plans/voip-integration-tests-phase-2.md b/docs/plans/voip-integration-tests-phase-2.md deleted file mode 100644 index eca9e0f51c5..00000000000 --- a/docs/plans/voip-integration-tests-phase-2.md +++ /dev/null @@ -1,297 +0,0 @@ -# VoIP Integration Tests — Phase 2 - -**File renamed + extended:** `app/containers/NewMediaCall/NewMediaCall.integration.test.tsx` → `app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx` (see §1 for rationale; CI-glob audit below shows no references to the old filename). -**Scope:** Add 3 integration paths (incoming answerCall, hang up, mute/hold) and fix two hygiene issues (blanket `console.error` suppression, missing `act()` wrappers). **startCall rejection is deferred to Phase 3** — see ADR Follow-up #1. -**Non-goals:** No production-code changes. No unit-level store tests that bypass real handlers. No rejection-path test until the unhandled-promise fix lands. - -**CI-glob audit:** `grep testMatch|testRegex|integration\.test|NewMediaCall` across `jest.config*`, `package.json`, `.github/**/*.{yml,yaml}` returns **no matches** referencing the old filename. Jest default `*.test.tsx` picks up the renamed file automatically. Safe to rename. - ---- - -## RALPLAN-DR Summary - -### Principles -1. **Exercise real handlers, not call counts.** Every test must travel through real `MediaSessionInstance` / `useCallStore` code. If an assertion can be satisfied by stubbing the method under test, it is the wrong assertion. -2. **Mock only at the SDK boundary.** `@rocket.chat/media-signaling`, `RNCallKeep`, `InCallManager`, `Navigation`, native modules — nothing internal. -3. **No blanket `console.error` suppression.** Suppression belongs per-test with `toHaveBeenCalledWith(...)` assertions, so `act()` warnings and real bugs surface. -4. **Wrap every emitter-driven state mutation in `act()`.** `emitter.emit(...)` fires synchronous React `set` calls through Zustand subscribers; these must be inside `act`. -5. **Minimum new surface area.** Reuse `mockCallEmitter`, `makeIncomingCall`, the existing `MockMediaSignalingSession`. Extend — don't duplicate. - -### Decision Drivers (top 3) -1. **Coverage gap severity.** answerCall + endCall + mute/hold are the call lifecycle's core; currently zero integration coverage. -2. **Mock surface cost.** Adding mocks for `InCallManager`, `getCallData`, `accept`, `setMuted`, `setHeld`, `localParticipant.setMuted` increases maintenance weight; minimize via shared helpers. -3. **Test reliability.** Async `answerCall` + React effects + Zustand subscriptions are prone to `act()` warnings and flakes — we must handle them, not hide them. - -### Viable Options - -#### Option A — All paths in existing file, keep current filename -- **Pros:** Zero file-structure churn; reuses beforeEach scaffolding; fastest to land; diff stays local. -- **Cons:** Filename `NewMediaCall.integration.test.tsx` becomes a lie — it mostly tests `MediaSessionInstance` + `useCallStore`, not the `NewMediaCall` component. Name/content mismatch erodes file-tree navigability. File grows from ~412 to ~700+ LOC. - -#### Option A′ — Rename to `VoipCallLifecycle.integration.test.tsx`, single-file structure **[SELECTED]** -- **Pros:** Filename matches intent (the call lifecycle: outgoing press, incoming answer, hangup, controls); no mock duplication; same single-file benefits as A; CI-glob audit shows no references to old name so rename is a pure `git mv` + one `jest.mock` path recheck. -- **Cons:** One extra commit for the rename; git history for the file becomes slightly harder to blame pre-rename (mitigated by `git log --follow`). - -#### Option B — Split into sibling files -- `VoipCallLifecycle.outgoing.integration.test.tsx`, `.answerCall.integration.test.tsx`, `.endCall.integration.test.tsx`, `.controls.integration.test.tsx` -- **Pros:** Each file <250 LOC; focused failures; parallel Jest shard friendly. -- **Cons:** 4× duplicated `jest.mock(...)` boilerplate (~130 LOC of mocks per file); drift risk between files; onboarding cost. - -#### Option C — Shared helper module + split -- Extract `app/containers/NewMediaCall/__integration__/setup.ts` exporting mock factories and lifecycle hooks, then split like Option B. -- **Pros:** Clearest separation; one source of mock truth; each test file reads as intent-only. -- **Cons:** Upfront refactor of the existing passing suite (regression risk on 6 green tests); helpers with side effects (jest.mock hoisting) are subtle; Jest factory hoisting makes extracting `jest.mock` calls non-trivial (must stay in test files). - -### Selected: **Option A′** — rename to `VoipCallLifecycle.integration.test.tsx`, single-file structure with inline helpers - -**Why:** Filename honesty matters. The current `NewMediaCall.integration.test.tsx` already spans three owners (`NewMediaCall` component press path, `MediaSessionInstance` handlers, `useCallStore` actions) and the new tests double down on the latter two. Renaming to `VoipCallLifecycle.integration.test.tsx` makes the file's genre clear; CI-glob audit confirms no config references the old name. Jest's `jest.mock` hoisting rule forbids cleanly extracting mock factories into sibling modules — Option C's headline benefit is not achievable. Option B's mock-duplication cost is real and invites drift across 4 files covering tightly-coupled code paths. Option A′ keeps the 6 existing green tests untouched (except the rename), leverages the existing `MockMediaSignalingSession` infrastructure, and bounded file growth (~250 net new LOC for 9 new tests; Phase 3 would add ~40 more) is acceptable for cohesive call-lifecycle coverage. We will add small inline helpers (`makeSyntheticCall`, `emitDDPMediaSignal`) inside the test file to keep intent readable without cross-file helpers. - -File-size guidance is **advisory, not enforceable**: if the file grows past ~800 LOC after Phase 3, consider splitting via Option B. Growth alone does not block landing. - ---- - -## Plan Body - -### 1. File-Structure Decision -**Rename then extend.** Step 1: `git mv app/containers/NewMediaCall/NewMediaCall.integration.test.tsx app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx`. Step 2: update the top-level `describe` label to `'VoIP call lifecycle (integration)'`. Step 3: add new tests as nested `describe` blocks with genre labels so future file-tree scans know what each block owns: -- `describe('MediaSessionInstance contract: answerCall', …)` -- `describe('UI store contract: Hang up', …)` -- `describe('UI store contract: In-call controls (mute/hold)', …)` - -Each `describe` gets its own inner `beforeEach` only if it needs extra setup beyond the outer one. - -**CI verification (performed):** `grep -r 'NewMediaCall\.integration'` returned only the test file itself — no Jest config, no package.json script, no GitHub Actions workflow references the old filename. Jest's default `testMatch` picks up `**/*.test.tsx`, so the rename is pure. - -### 2. Shared In-File Helpers (add near existing `makeIncomingCall`) - -#### `makeSyntheticCall(overrides)` — extends `makeIncomingCall` -Returns an `IClientMediaCall` with extra jest.fn methods that real code paths exercise: -- `accept: jest.fn().mockResolvedValue(undefined)` — needed by `answerCall` -- `hangup: jest.fn()`, `reject: jest.fn()` — already present -- `localParticipant.setMuted: jest.fn()` -- `localParticipant.setHeld: jest.fn()` -- `sendDTMF: jest.fn()` (defensive; `setDialpadValue` uses it but not in these tests) -- `state: 'ringing' | 'active'` configurable (drives ringing→reject branch in instance endCall) -- `emitter: mockCallEmitter()` — real emit-dispatch so `trackStateChange` reaches store subscribers - -#### `emitDDPMediaSignal(session, signal)` helper -Drives the same payload shape `MediaSessionInstance.mediaSignalListener` expects. Because `sdk.onStreamData` is mocked to return `{ stop: jest.fn() }` with no callback capture, we need to capture the handler. **Modify the existing `sdk` mock** to capture the callback: -```ts -let capturedStreamHandler: ((msg: IDDPMessage) => void) | null = null; -jest.mock('../../lib/services/sdk', () => ({ - default: { - onStreamData: jest.fn((_name, handler) => { - capturedStreamHandler = handler; - return { stop: jest.fn() }; - }), - methodCall: jest.fn() - } -})); -``` -Then `emitDDPMediaSignal(signal)` calls `capturedStreamHandler({ fields: { eventName: `${userId}/media-signal`, args: [signal] } })`. This is the **one** non-trivial mock extension required. - -**Explicit reset in `beforeEach`:** capturing into a module-level variable creates test-pollution risk across suites. The outer `beforeEach` must reset it: `capturedStreamHandler = null;` before `mediaSessionInstance.init('me')` runs (which re-registers the handler). Without this reset a test that forgets to re-init could accidentally invoke a stale handler from a previous test's session. - -#### DDP signal schema — verified against SDK types - -The exact gate in `app/lib/services/voip/MediaSessionInstance.ts:75-85` is: - -```ts -if ( - signal.type === 'notification' && - signal.notification === 'accepted' && - signal.signedContractId === getUniqueIdSync() && - nativeAcceptedCallId === signal.callId && - call == null -) { - this.answerCall(signal.callId).catch(...); -} -``` - -Cross-checked against `@rocket.chat/media-signaling/dist/lib/Session.js:139-143`: the SDK itself uses `signal.type === 'notification'` + `signal.signedContractId` + `signal.notification === 'accepted'` as the wire contract — **not** `'contractNotification'`. The string `contractNotification` does not appear in the SDK's type definitions (searched `node_modules/@rocket.chat/media-signaling/dist/**/*.d.ts` — no hits). Tests must use the exact object: - -```ts -{ type: 'notification', notification: 'accepted', signedContractId: 'test-device-id', callId: '' } -``` - -`getUniqueIdSync` is mocked to return `'test-device-id'`, so `signedContractId` must equal that literal. - -### 3. New Top-Level Mocks Required - -- **`react-native-incall-manager`** — `useCallStore.setCall` calls `InCallManager.start`; `reset` calls `InCallManager.stop`. Add: - ```ts - jest.mock('react-native-incall-manager', () => ({ - __esModule: true, - default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn().mockResolvedValue(undefined) } - })); - ``` - Without it, `setCall` currently logs `InCallManager.start failed` via the already-suppressed `console.error` — another reason to remove blanket suppression. - -- **`sdk.onStreamData` callback capture** — see §2. - -No other top-level mock additions. - -### 4. Tests to Add - -> **Assertion philosophy (applies to all blocks below):** Per Principle #1, we assert against the *real integration seams* — boundary mocks (`RNCallKeep.*`, `Navigation.*`, `InCallManager.*`) and observable store state (`useCallStore.getState().X`). We avoid redundant `toHaveBeenCalled` assertions on inner method calls (e.g., `call.hangup`, `localParticipant.setMuted`) when the resulting store-state assertion already proves the real handler ran. Call-count assertions are kept **only** for boundary mocks, since those are the genuine observable seams with the outside world. - -#### 4a. MediaSessionInstance contract: answerCall - -**Test A1 — accepted signal + native pre-accept → answerCall navigates** -- Setup: after outer `beforeEach`, `useCallStore.getState().setNativeAcceptedCallId('incoming-1')`, mock `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'incoming-1' }))`. -- Drive: `await act(async () => { emitDDPMediaSignal({ type: 'notification', notification: 'accepted', signedContractId: 'test-device-id', callId: 'incoming-1' }); });` — `answerCall` is async, must flush microtasks. -- Assert (boundary + state): `RNCallKeep.setCurrentCallActive('incoming-1')` called; `Navigation.navigate('CallView')` called; `useCallStore.getState().call?.callId === 'incoming-1'`. (No redundant `mainCall.accept` call-count — if `call` is in the store, `accept` ran.) - -**Test A2 — call not found branch** -- Setup: `setNativeAcceptedCallId('missing-1')`, `session.getCallData.mockReturnValue(undefined)`. -- Drive: same DDP signal shape with `callId: 'missing-1'`. -- Assert: `RNCallKeep.endCall('missing-1')` called; `useCallStore.getState().nativeAcceptedCallId === null`; `Navigation.navigate` NOT called; `useCallStore.getState().call === null`. - -**Test A3 — idempotency branch (existing call matches)** -- **Test-pollution guard (first line):** `expect(useCallStore.getState().nativeAcceptedCallId).toBe(null);` — fails fast if a prior test leaked state despite the outer `reset()`. -- Setup: pre-populate store via `act(() => useCallStore.getState().setCall(existingCall))` where `existingCall.callId === 'incoming-1'`; then `(Navigation.navigate as jest.Mock).mockClear();` to ignore the setup's navigate. -- Drive: `await mediaSessionInstance.answerCall('incoming-1')` directly (the DDP gate checks `call == null`, which we cannot satisfy here, so we test the public method directly — this is still integration because we assert real guard logic, and A1 already covers the DDP entry). -- Assert (boundary only): `Navigation.navigate` NOT called; `RNCallKeep.setCurrentCallActive` NOT called; `(session.getCallData as jest.Mock).toHaveBeenCalledTimes(0)` — this **is** a valid call-count assertion because `getCallData` is the SDK boundary, confirming the early-return happened before any SDK interaction. - -**Mock extensions needed:** `session.getCallData` per-test `mockReturnValue`. Synthetic call needs `accept: jest.fn().mockResolvedValue(undefined)`. - -#### 4b. UI store contract: Hang up - -> **Important clarification:** The CallView end button wires to `useCallStore.endCall` (confirmed at `app/views/CallView/components/CallButtons.tsx:44,65`), NOT `MediaSessionInstance.endCall`. The latter is called from native CallKit "end" events and other entry points. Both need coverage. - -**Test B1 — UI-triggered `useCallStore.endCall`** -- Setup: complete outgoing flow via existing press path so `setCall` binds listeners and populates the store. -- Drive: `act(() => { useCallStore.getState().endCall(); });` -- Assert (boundary + state): `RNCallKeep.endCall('call-user-1')` called; `InCallManager.stop` called (via `reset`); `useCallStore.getState().call === null`; `useCallStore.getState().callId === null`. (No redundant `call.hangup` call-count — store `call === null` proves `endCall` ran through `reset`.) - -**Test B2 — `MediaSessionInstance.endCall` during active state → hangup** -- Setup: `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'active-1', state: 'active' }))`. -- Drive: `act(() => { mediaSessionInstance.endCall('active-1'); });` -- Assert (boundary + state): `RNCallKeep.endCall('active-1')`; `RNCallKeep.setCurrentCallActive('')`; `RNCallKeep.setAvailable(true)`; `useCallStore.getState().call === null`. (No redundant `mainCall.hangup`/`mainCall.reject` counts — the branch is an internal implementation detail; what matters is the store reset and RNCallKeep cleanup.) - -**Test B3 — `MediaSessionInstance.endCall` during ringing → reject branch** -- Setup: `session.getCallData.mockReturnValue(makeSyntheticCall({ callId: 'ringing-1', state: 'ringing' }))`. -- Drive: `act(() => { mediaSessionInstance.endCall('ringing-1'); });` -- Assert (boundary + state): `RNCallKeep.endCall('ringing-1')`; `useCallStore.getState().call === null`. (Branch differentiation between ringing→reject vs active→hangup is covered by the SDK's own tests; our integration value is that the RNCallKeep/store cleanup runs regardless of branch.) - -**Mock extensions needed:** none new beyond §2 helpers. - -#### 4c. UI store contract: In-call controls (mute/hold) - -**Test C1 — `toggleMute` → store `isMuted` flips** -- Setup: complete outgoing flow so `setCall` has wired listeners. Pre-assertion: `useCallStore.getState().isMuted === false`. -- Drive: `act(() => { useCallStore.getState().toggleMute(); });` -- Assert (state): `useCallStore.getState().isMuted === true`. -- Second press: `act(() => useCallStore.getState().toggleMute())` → `isMuted === false`. (No call-count on `localParticipant.setMuted` — store state transition proves the action ran the real handler, and `setMuted` is an internal SDK method not a boundary.) - -**Test C2 — `toggleHold` → store `isOnHold` flips** -- Same pattern; `isOnHold` goes `false → true → false`. No call-count on `setHeld`. - -**Test C3 — `trackStateChange` emission syncs store from call** -- Setup: outgoing flow. Mutate synthetic call fields to simulate SDK side: `call.localParticipant.muted = true; call.remoteParticipants[0].held = true;` -- Drive: `act(() => { (call.emitter as unknown as ReturnType).emit('trackStateChange'); });` -- Assert (state): `useCallStore.getState().isMuted === true`; `useCallStore.getState().remoteHeld === true`; `useCallStore.getState().controlsVisible === true`. - -**Mock extensions needed:** `localParticipant.setMuted`, `localParticipant.setHeld` as `jest.fn()` on `makeSyntheticCall` (still required — the real `toggleMute`/`toggleHold` calls them and would throw without a stub, even though we don't assert on the call). - -#### 4d. startCall rejection path — **DEFERRED to Phase 3** - -This test is intentionally removed from Phase 2. See ADR Follow-up #1 for rationale: the production code at `MediaSessionInstance.ts:151-155` does not `await` or `.catch` the SDK's `startCall` promise, so a rejection leaks as an unhandled promise. Writing a test for this behavior would either (a) force us to accept unhandled-rejection noise in Jest output, contradicting the Success Criterion "no unhandled promise rejections", or (b) require masking the rejection in a way that the production code does not. The correct sequencing is: **Phase 3 lands a `.catch` in production code, then writes the integration test against the fixed behavior.** Doing the test before the fix would bake today's bug into the regression suite. - -### 5. `consoleErrorSpy` Fix — Spike First, Then Decide Per-Warning - -**Do not blindly delete the blanket spy.** Instead, treat this as a time-boxed spike: - -**Step 5a — Spike:** -1. Remove the blanket `jest.spyOn(console, 'error').mockImplementation(() => {})` from outer `beforeEach` and its `afterEach` restore. -2. Run the existing 6 tests: `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'`. -3. Capture every `console.error` and every React `act()` warning that surfaces. Produce a table: `| warning text (first 80 chars) | source | count |`. - -**Step 5b — Classify each warning and apply the matching remedy:** -- **(a) Fix at the root** — if the warning points to a real `act()` gap (our code), wrap the offending call and confirm the warning disappears. This is the preferred path. -- **(b) Narrow per-test spy with exact-string matcher** — if the warning is deterministic and tied to a specific test path (e.g., `MediaSessionInstance` logs `[VoIP] Error resolving room id from contact` when `navigateToCallRoom` is unavailable), use `jest.spyOn(console, 'error').mockImplementation((msg: string) => { if (!msg.includes('')) { throw new Error('unexpected console.error: ' + msg); } });` scoped to just that test. The `throw on mismatch` pattern is the key — it preserves the spy's safety net. -- **(c) Narrow-scoped known library noise** — *escape clause:* if the spike reveals warnings from 3rd-party code we cannot fix at source (e.g., `@expo/vector-icons` font-loading warning, React Native logbox noise), an exact-string `includes(…)` suppression in the outer `beforeEach` is acceptable **provided each suppressed substring is explicitly commented with the library source and why it's unfixable**. This is a permitted compromise, not a blanket fallback. Prefer (a) then (b); reach for (c) only when neither applies. - -**Step 5c — Re-run until green.** The non-option is reinstating a blanket `() => {}` suppressor with no substring matcher. Everything else (fix, narrow spy, or commented substring allowlist) is on the table. - -### 6. `act()` Wrapper Audit - -Every synchronous or async React-state-mutating call inside a test must be wrapped. - -**Existing tests (line references from current file):** -- Line 306: `(call!.emitter …).emit('ended')` — **MISSING `act()`.** Add: `act(() => { (call!.emitter …).emit('ended'); });` — the emitter triggers `handleEnded` → `get().reset()` → `setState` + `Navigation.back`, all synchronous React-reachable state. -- Line 355: `session.emit('newCall', { call: hiddenCall })` — the handler early-returns for hidden calls, so no state mutation occurs. Still safer to wrap in `act()` for future-proofing; mandatory if the early-return is removed. -- Line 365: `session.emit('newCall', { call: incomingCall })` — the handler runs but `role === 'callee'` skips `setCall`/navigate. `call.emitter.on('ended', …)` is wired though (line 105-107 of MediaSessionInstance), which does not mutate React state but does register a listener. `act()` wrap is not strictly needed but recommended for consistency. -- Line 397: `useCallStore.getState().setCall(call)` — **MISSING `act()`.** `setCall` mutates Zustand state that the rendered CallView subscribes to. Wrap. -- Line 407: `useCallStore.setState({ call: null })` — already wrapped in `act()`, good. - -**New tests — act() required at every listed emit/setState point:** -- A1: `await act(async () => { emitDDPMediaSignal(...); });` -- A3: `act(() => useCallStore.getState().setCall(existingCall))` in setup. -- B1/B2/B3: `act(() => { useCallStore.getState().endCall(); });` / `act(() => { mediaSessionInstance.endCall(...); });` -- C1/C2: `act(() => { useCallStore.getState().toggleMute(); });` -- C3: `act(() => { (emitter as any).emit('trackStateChange'); });` - -### 7. Execution & Verification - -Plan step → verification: - -1. **Rename file:** `git mv NewMediaCall.integration.test.tsx VoipCallLifecycle.integration.test.tsx`; update top-level `describe` label; verify existing 6 tests still pass: `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'`. -2. Add `InCallManager` mock + `sdk.onStreamData` callback capture + `capturedStreamHandler` reset in `beforeEach` → rerun; 6 tests still green. -3. Fix existing `act()` gaps (lines 306, 397 of pre-rename file) → rerun; 6 green. -4. **§5a spike:** remove blanket `consoleErrorSpy`, rerun, catalog surfaced warnings. -5. **§5b classification:** apply remedies (a/b/c) per warning; rerun until 6 tests green with no warning noise in output. Decision log each remedy in the test file as a comment near the spy. -6. Add `makeSyntheticCall` + `emitDDPMediaSignal` helpers → compile check. -7. **Mid-plan lint check:** `yarn lint` clean on `app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx` — do not proceed if lint is red. -8. Add §4a tests (3: A1, A2, A3) → green. Confirm A3's test-pollution guard passes. -9. Add §4b tests (3: B1, B2, B3) → green. -10. Add §4c tests (3: C1, C2, C3) → green. -11. **Final:** `yarn lint` clean; `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'` shows **15 tests** total (6 existing + 9 new); full suite `TZ=UTC yarn test` green; no unhandled rejection warnings in Jest output; no stray `act()` warnings. - -Phase 3 (separate PR): land `.catch` on `MediaSessionInstance.ts:154`, then add the startCall-rejection integration test (→ 16 tests). - ---- - -## ADR — Integration Test Expansion for VoIP Call Lifecycle - -**Decision:** Rename the existing test file to `VoipCallLifecycle.integration.test.tsx` and extend it with **9 new tests** (A1-A3 answerCall, B1-B3 hang-up, C1-C3 controls). Replace blanket `console.error` suppression with a spike-driven per-warning remediation. Audit all `act()` wrappers. **Defer the startCall-rejection test to Phase 3** so the production `.catch` lands first. - -**Drivers:** -1. Zero integration coverage on answerCall/endCall/mute/hold despite these being the call lifecycle core. -2. The blanket `console.error` spy currently hides `act()` warnings and makes flakes likely. -3. Existing `MockMediaSignalingSession` + `mockCallEmitter` infrastructure already handles real handler dispatch — incremental tests cost far less than alternative designs. - -**Alternatives Considered:** -- **Keep filename `NewMediaCall.integration.test.tsx` (Option A).** Rejected: name/content mismatch worsens as we add `MediaSessionInstance`- and `useCallStore`-centric tests. -- **Split into 4 sibling files (Option B).** Rejected due to ~130 LOC of mock duplication per file, drift risk, and Jest mock-hoisting constraints preventing clean helper extraction. -- **Shared helper module (Option C).** Rejected because `jest.mock` factories cannot be cleanly extracted without losing hoisting semantics; benefit does not justify complexity. -- **Keeping `consoleErrorSpy` blanket suppression.** Rejected — it hides `act()` warnings, which are strong signals of real async/React bugs. The §5 spike replaces it with per-warning treatment. -- **Writing startCall-rejection test in Phase 2.** Rejected because the production code leaks an unhandled promise; the test would either bake the bug into the regression suite or contradict the no-unhandled-rejection success criterion. -- **Testing via React Testing Library + real CallView buttons instead of direct store calls.** Rejected for mute/hold/endCall because the CallView button tests (`app/views/CallView/index.test.tsx:253-265`) already exist and mock store actions. The new value here is proving the *store action* runs real `IClientMediaCall` methods — direct `useCallStore.getState().X()` is more precise for that contract. Outgoing-call path already proves the button→store wiring. - -**Why Chosen:** Option A′ (rename + single-file) preserves 6 green tests with a trivial `git mv`, produces an honest filename, leverages existing infrastructure, minimizes diff size, and lets us surface (not hide) async warnings. All new coverage travels through real `MediaSessionInstance` and `useCallStore` handlers with assertions on real boundary seams only (Principle #1). Deferring startCall rejection to Phase 3 sequences the code fix before the test that depends on it. - -**Consequences:** -- File grows from ~412 to ~670 LOC (9 new tests). Acceptable given cohesion. The ~800 LOC reference is **advisory, not enforceable** — do not block on it. -- Adding `InCallManager` mock means future tests that exercise speaker toggle get setup for free. -- Capturing the `sdk.onStreamData` callback makes DDP-driven tests trivial for future signal types. -- Removing blanket `console.error` suppression may expose latent issues in the existing 6 tests on first run — the §5 spike budgets this. -- The rename creates one `git log --follow`-ish hurdle for pre-rename blame; acceptable. - -**Follow-ups (owners + phase tags):** -1. **[Phase 3 — owner: @voip-team]** Land `.catch` (or convert to `async`) on `MediaSessionInstance.ts:151-155`'s `this.instance?.startCall(actor, userId)`. Then add integration test D1 (rejected `session.startCall` → no navigate, no `setCall`, no unhandled-rejection in Jest output). -2. **[Phase 3 — owner: @voip-team]** Fix the `useCallStore.getState().setCall(call)` outside `act()` at what was line 397 of the original file. Confirm the test's intent remains (`setCall` populates store → `CallView` renders → `setState({ call: null })` unmounts). If behavior changes after `act()` is added, reconsider the test's shape. -3. **[Advisory]** If the file exceeds ~800 LOC after Phase 3, split into Option B shape. Mechanical copy of `describe` blocks and mock boilerplate. Not a blocker. -4. **[Advisory]** Extract `mockCallEmitter` and `makeSyntheticCall` to a test-helpers module only if a second integration test file needs them. Premature today. - ---- - -## Success Criteria - -- File renamed to `VoipCallLifecycle.integration.test.tsx`; `TZ=UTC yarn test --testPathPattern='VoipCallLifecycle.integration'` picks it up. -- **9 new tests added** (A1-A3, B1-B3, C1-C3); existing 6 tests still pass; **15 tests total in Phase 2**. -- Phase 3 (separate PR, owner @voip-team) adds the 16th test after the `.catch` fix. -- No `act()` warnings in Jest output (verified post-spike per §5). -- No unhandled promise rejections in Jest output. -- `yarn lint` clean on the renamed file (mid-plan check at step 7.7 and final at step 7.11). -- ADR follow-ups #1 and #2 filed as Phase 3 tasks with @voip-team owner. From 7f77d8c63a1d7ec1ce53aa2498a369f52cad85b5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 15:55:06 -0300 Subject: [PATCH 08/12] =?UTF-8?q?test(voip):=20rename=20NewMediaCall.integ?= =?UTF-8?q?ration.test.tsx=20=E2=86=92=20VoipCallLifecycle.integration.tes?= =?UTF-8?q?t.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filename now reflects genre (call lifecycle across MediaSessionInstance + useCallStore), not just the press component. CI-glob audit: no config references the old name; Jest default testMatch picks up the renamed file. --- ...ntegration.test.tsx => VoipCallLifecycle.integration.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/containers/NewMediaCall/{NewMediaCall.integration.test.tsx => VoipCallLifecycle.integration.test.tsx} (100%) diff --git a/app/containers/NewMediaCall/NewMediaCall.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx similarity index 100% rename from app/containers/NewMediaCall/NewMediaCall.integration.test.tsx rename to app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx From 85be5fcf163054e3d96ff1ee0be8cac223f1a445 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 15 Apr 2026 16:04:23 -0300 Subject: [PATCH 09/12] =?UTF-8?q?test(voip):=20expand=20VoipCallLifecycle?= =?UTF-8?q?=20integration=20coverage=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 9 tests covering the call-lifecycle handlers previously untested: - answerCall (A1/A2/A3): DDP accepted signal drives real answerCall; not-found branch clears native id; idempotent when call already bound. - endCall (B1/B2/B3): UI useCallStore.endCall + MediaSessionInstance.endCall for both ringing and active states, asserting RNCallKeep cleanup and store reset. - In-call controls (C1/C2/C3): toggleMute / toggleHold flip store state; trackStateChange emission syncs isMuted/remoteHeld/controlsVisible. Infrastructure changes: - Mock react-native-incall-manager so setCall/reset do not log errors. - Capture sdk.onStreamData handler in mockSdkState so tests can drive DDP signals through the real MediaSessionInstance listener. - Add makeSyntheticCall + emitDDPMediaSignal inline helpers. - Replace the blanket console.error spy with a narrow allowlist spy that throws on unexpected errors/warns. Known noise is documented inline. - Wrap emitter/setCall/setState mutations in act() wherever missing. startCall rejection path deferred to Phase 3: production code does not catch the SDK's startCall promise, so the test needs the fix first. --- .../VoipCallLifecycle.integration.test.tsx | 440 ++++++++++++++++-- 1 file changed, 395 insertions(+), 45 deletions(-) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index 2ace23de18f..94577136b86 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -1,20 +1,21 @@ -// app/containers/NewMediaCall/NewMediaCall.integration.test.tsx +// app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx // -// Integration test: CreateCall button press → MediaSessionInstance → CallView. +// Integration tests covering the VoIP call lifecycle across real handlers in +// MediaSessionInstance and useCallStore: +// - Outgoing (press Call → startCall → newCall → setCall + navigate) +// - Incoming (DDP accepted signal → answerCall → setCall + navigate) +// - Hang up (UI endCall and MediaSessionInstance.endCall ringing/active branches) +// - In-call controls (mute, hold, trackStateChange sync) // -// Seam: @rocket.chat/media-signaling is mocked at the SDK boundary. -// The mock MediaSignalingSession simulates the real SDK's async behaviour: -// session.startCall(actor, userId) → fires 'newCall' with a synthetic call. -// This closes the causal chain — fireEvent.press alone causes Navigation.navigate. -// -// Real code running: MediaSessionInstance, useCallStore, NewMediaCall, CallView. -// Mocked at boundary: MediaSignalingSession (SDK), Navigation, RNCallKeep, -// DDP SDK, WebRTC, native device modules (not available in Jest). +// Seam: @rocket.chat/media-signaling is mocked at the SDK boundary. Everything +// between the SDK and the UI — MediaSessionInstance, useCallStore, NewMediaCall, +// CallView — runs as real code. import React from 'react'; import { act, fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import RNCallKeep from 'react-native-callkeep'; +import InCallManager from 'react-native-incall-manager'; import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { NewMediaCall } from './NewMediaCall'; @@ -26,6 +27,7 @@ import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstan import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; +import type { IDDPMessage } from '../../definitions/IDDPMessage'; // Compile-time guard — fails tsc if 'CallView' is removed from InsideStackParamList. const assertType = <_T extends true>(_?: _T): void => {}; @@ -33,7 +35,12 @@ assertType(); // ─── Mocks ──────────────────────────────────────────────────────────────────── -let consoleErrorSpy: ReturnType | undefined; +// Mock-prefixed holder for the captured sdk.onStreamData handler. +// Jest hoists jest.mock() to the top of the module; factories can only close +// over variables whose names start with "mock". Wrapping in an object lets us +// reassign across the beforeEach without recreating the module mock. +const mockSdkState: { streamHandler: ((msg: IDDPMessage) => void) | null } = { streamHandler: null }; + jest.mock('../../lib/database', () => ({ db: { get: jest.fn() }, active: { get: jest.fn() } @@ -51,7 +58,12 @@ jest.mock('../../lib/navigation/appNavigation', () => ({ jest.mock('../../lib/services/sdk', () => ({ __esModule: true, default: { - onStreamData: jest.fn(() => ({ stop: jest.fn() })), + // Capture the stream handler so tests can drive DDP signals directly — + // the real path is sdk.onStreamData → handler → this.instance.processSignal. + onStreamData: jest.fn((_name: string, handler: (msg: IDDPMessage) => void) => { + mockSdkState.streamHandler = handler; + return { stop: jest.fn() }; + }), methodCall: jest.fn() } })); @@ -78,6 +90,17 @@ jest.mock('react-native-callkeep', () => ({ setAvailable: jest.fn() } })); +// useCallStore.setCall/reset call InCallManager.start/stop; without this mock +// those calls throw and the real error path goes through console.error, which +// the narrow spy below would flag as unknown noise. +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { + start: jest.fn(), + stop: jest.fn(), + setForceSpeakerphoneOn: jest.fn().mockResolvedValue(undefined) + } +})); jest.mock('../../lib/methods/helpers/fileDownload', () => ({ fileDownload: jest.fn(), fileDownloadAndPreview: jest.fn() @@ -157,11 +180,6 @@ function mockCallEmitter() { } // ─── Media-signaling mock ───────────────────────────────────────────────────── -// -// Key design: on() maintains a real handler registry so that session.emit() -// dispatches to registered handlers. startCall(actor, userId) fires 'newCall' -// synchronously, simulating the SDK's response after WebRTC negotiation. -// session.emit() lets tests drive incoming-call and branch scenarios directly. type MockMediaSignalingSession = { userId: string; @@ -194,7 +212,6 @@ jest.mock('@rocket.chat/media-signaling', () => ({ handlers[event].push(handler); }); - // Allows tests to simulate incoming-call and branch scenarios. this.emit = (event: string, payload: unknown) => { handlers[event]?.forEach(h => h(payload)); }; @@ -202,18 +219,27 @@ jest.mock('@rocket.chat/media-signaling', () => ({ this.processSignal = jest.fn().mockResolvedValue(undefined); this.setIceGatheringTimeout = jest.fn(); - // Integration seam: startCall fires 'newCall' with a synthetic outgoing call, - // connecting fireEvent.press → startCall → newCall → setCall + navigate. + // Integration seam: startCall fires 'newCall' with a synthetic outgoing call. + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.startCall = jest.fn().mockImplementation((_actor: string, userId: string) => { const call: IClientMediaCall = { callId: `call-${userId}`, hidden: false, state: 'ringing', - localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, + localParticipant: { + local: true, + role: 'caller', + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }, remoteParticipants: [{ local: false, role: 'callee', muted: false, held: false, contact: {} }], reject: jest.fn(), hangup: jest.fn(), + sendDTMF: jest.fn(), emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] } as unknown as IClientMediaCall; self.emit('newCall', { call }); @@ -228,39 +254,155 @@ jest.mock('@rocket.chat/media-signaling', () => ({ // ─── Helpers ────────────────────────────────────────────────────────────────── -function makeIncomingCall(options: { - callId?: string; - role?: 'caller' | 'callee'; - hidden?: boolean; -}): IClientMediaCall { +function makeIncomingCall(options: { callId?: string; role?: 'caller' | 'callee'; hidden?: boolean }): IClientMediaCall { return { callId: options.callId ?? 'incoming-call', hidden: options.hidden ?? false, state: 'ringing', - localParticipant: { local: true, role: options.role ?? 'callee', muted: false, held: false, contact: {} }, - remoteParticipants: [{ local: false, role: options.role === 'caller' ? 'callee' : 'caller', muted: false, held: false, contact: {} }], + localParticipant: { + local: true, + role: options.role ?? 'callee', + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }, + remoteParticipants: [ + { local: false, role: options.role === 'caller' ? 'callee' : 'caller', muted: false, held: false, contact: {} } + ], reject: jest.fn(), hangup: jest.fn(), + sendDTMF: jest.fn(), emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] } as unknown as IClientMediaCall; } +// Extended synthetic call with all methods the real handlers exercise: +// accept (answerCall), setMuted/setHeld (toggleMute/toggleHold), hangup/reject (endCall). +function makeSyntheticCall(overrides: { + callId?: string; + state?: 'ringing' | 'active' | 'accepted' | 'ended' | 'none'; + role?: 'caller' | 'callee'; + remoteMuted?: boolean; + remoteHeld?: boolean; +}): IClientMediaCall { + const callId = overrides.callId ?? 'synthetic-call'; + return { + callId, + hidden: false, + state: overrides.state ?? 'ringing', + localParticipant: { + local: true, + role: overrides.role ?? 'callee', + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }, + remoteParticipants: [ + { + local: false, + role: overrides.role === 'caller' ? 'callee' : 'caller', + muted: overrides.remoteMuted ?? false, + held: overrides.remoteHeld ?? false, + contact: { displayName: 'Remote', username: 'remote', sipExtension: '' } + } + ], + accept: jest.fn().mockResolvedValue(undefined), + reject: jest.fn(), + hangup: jest.fn(), + sendDTMF: jest.fn(), + emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; +} + +// Drive the same payload shape the production DDP listener expects. +// Wraps the signal in an IDDPMessage and invokes the captured handler. +function emitDDPMediaSignal(signal: Record): void { + if (!mockSdkState.streamHandler) { + throw new Error('emitDDPMediaSignal called before sdk.onStreamData registered a handler'); + } + mockSdkState.streamHandler({ + fields: { eventName: 'test-device-id/media-signal', args: [signal] } + } as unknown as IDDPMessage); +} + function setSelectedPeer(peer: TPeerItem): void { usePeerAutocompleteStore.setState({ selectedPeer: peer, options: [], filter: '' }); } const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; +// ─── Console-error handling ─────────────────────────────────────────────────── +// +// Replaces the previous blanket `jest.spyOn(console, 'error').mockImplementation(() => {})` +// with a narrow allowlist. Known noise substrings are documented below; any +// other console.error/warn causes the test to fail. This surfaces real act() +// warnings and new bugs instead of hiding them. +// +// Known-noise allowlist: +// - '@expo/vector-icons' Icon warn: fires on CallView render because the +// icon font has not been loaded in the Jest environment. 3rd-party. +// - 'not wrapped in act(...)' warnings tied to the Icon class component's +// render inside — same root cause as above. +// - 'is not a valid icon name' — CallView's CustomIcon reports missing +// glyph entries (e.g. 'pause-shape-unfilled') in the Jest env; glyph map +// is not loaded. Rendering succeeds, warning is cosmetic for tests. +// - '[VoIP] Call not found:' — deterministic expected branch output of +// answerCall when getCallData returns undefined (exercised by test A2). +// Production code warns intentionally; tests should not hide it, but it +// is not an unexpected error for the asserting test. +const CONSOLE_ERROR_ALLOWLIST: string[] = []; +const CONSOLE_WARN_ALLOWLIST: string[] = [ + '@expo/vector-icons', + 'not wrapped in act', + 'is not a valid icon name', + '[VoIP] Call not found:' +]; + +let consoleErrorSpy: jest.SpyInstance | undefined; +let consoleWarnSpy: jest.SpyInstance | undefined; +let unexpectedConsoleErrors: string[] = []; + +function formatConsoleArgs(args: unknown[]): string { + return args + .map(a => { + if (a instanceof Error) return a.message; + if (typeof a === 'string') return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(' '); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── -describe('NewMediaCall → CallView (integration)', () => { +describe('VoIP call lifecycle (integration)', () => { beforeEach(() => { jest.clearAllMocks(); createdSessions.length = 0; + mockSdkState.streamHandler = null; + unexpectedConsoleErrors = []; usePeerAutocompleteStore.getState().reset(); useCallStore.getState().reset(); mediaSessionInstance.reset(); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + const message = formatConsoleArgs(args); + if (CONSOLE_ERROR_ALLOWLIST.some(allowed => message.includes(allowed))) return; + unexpectedConsoleErrors.push(`[error] ${message}`); + }); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + const message = formatConsoleArgs(args); + if (CONSOLE_WARN_ALLOWLIST.some(allowed => message.includes(allowed))) return; + unexpectedConsoleErrors.push(`[warn] ${message}`); + }); + mediaSessionInstance.init('me'); if (createdSessions.length !== 1) { throw new Error(`Expected exactly one media session after init, got ${createdSessions.length}`); @@ -270,8 +412,14 @@ describe('NewMediaCall → CallView (integration)', () => { afterEach(() => { consoleErrorSpy?.mockRestore(); + consoleWarnSpy?.mockRestore(); consoleErrorSpy = undefined; + consoleWarnSpy = undefined; mediaSessionInstance.reset(); + if (unexpectedConsoleErrors.length > 0) { + const joined = unexpectedConsoleErrors.join('\n - '); + throw new Error(`Unexpected console.error/warn in test:\n - ${joined}`); + } }); // ── Outgoing calls (button press path) ─────────────────────────────────── @@ -293,18 +441,18 @@ describe('NewMediaCall → CallView (integration)', () => { // → useCallStore.setCall + Navigation.navigate('CallView') fireEvent.press(getByTestId('new-media-call-button')); - // SDK-boundary: args are (actor, userId) — reversed from the public API expect(session.startCall).toHaveBeenCalledWith('user', 'user-1'); expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); expect(mockHideActionSheet).toHaveBeenCalledTimes(1); - const call = useCallStore.getState().call; + const { call } = useCallStore.getState(); expect(call?.callId).toBe('call-user-1'); - // Behavioral: firing 'ended' triggers RNCallKeep cleanup and navigation back. - // Real emitter dispatches to both handlers wired by MediaSessionInstance and useCallStore. - (call!.emitter as unknown as ReturnType).emit('ended'); - expect((RNCallKeep.endCall as jest.Mock)).toHaveBeenCalledWith('call-user-1'); + // Firing 'ended' triggers RNCallKeep cleanup and navigation back via real handlers. + act(() => { + (call!.emitter as unknown as ReturnType).emit('ended'); + }); + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('call-user-1'); expect(Navigation.back).toHaveBeenCalled(); }); @@ -344,15 +492,14 @@ describe('NewMediaCall → CallView (integration)', () => { }); // ── newCall handler branches (incoming / SDK-driven path) ───────────────── - // These scenarios are triggered by the DDP listener in MediaSessionInstance, - // not by button press. We drive them via session.emit('newCall', ...) which - // exercises the same registered handler as the real DDP path. it('hidden call: newCall with hidden=true does not navigate or populate store', () => { const session = createdSessions[createdSessions.length - 1]; const hiddenCall = makeIncomingCall({ callId: 'hidden-1', hidden: true, role: 'caller' }); - session.emit('newCall', { call: hiddenCall }); + act(() => { + session.emit('newCall', { call: hiddenCall }); + }); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(useCallStore.getState().call).toBeNull(); @@ -362,16 +509,15 @@ describe('NewMediaCall → CallView (integration)', () => { const session = createdSessions[createdSessions.length - 1]; const incomingCall = makeIncomingCall({ callId: 'incoming-1', role: 'callee' }); - session.emit('newCall', { call: incomingCall }); + act(() => { + session.emit('newCall', { call: incomingCall }); + }); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(useCallStore.getState().call).toBeNull(); }); // ── CallView render contract ────────────────────────────────────────────── - // Verifies useCallStore.setCall produces state that CallView renders. - // Kept here because it proves the store → UI contract that the outgoing-call - // path relies on (MediaSessionInstance calls setCall before navigating). it('setCall populates store and CallView renders; clearing store unmounts it', () => { const emitter = mockCallEmitter(); @@ -394,7 +540,9 @@ describe('NewMediaCall → CallView (integration)', () => { emitter: emitter as unknown as IClientMediaCall['emitter'] } as unknown as IClientMediaCall; - useCallStore.getState().setCall(call); + act(() => { + useCallStore.getState().setCall(call); + }); const { getByTestId, queryByTestId } = render( @@ -409,4 +557,206 @@ describe('NewMediaCall → CallView (integration)', () => { }); expect(queryByTestId('call-view-container')).toBeNull(); }); + + // ── MediaSessionInstance contract: answerCall ──────────────────────────── + + describe('MediaSessionInstance contract: answerCall', () => { + it('A1: DDP accepted signal with native pre-accept → answerCall navigates to CallView', async () => { + const session = createdSessions[createdSessions.length - 1]; + const mainCall = makeSyntheticCall({ callId: 'incoming-1', role: 'callee' }); + session.getCallData.mockReturnValue(mainCall); + + act(() => { + useCallStore.getState().setNativeAcceptedCallId('incoming-1'); + }); + + await act(async () => { + emitDDPMediaSignal({ + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'incoming-1' + }); + // Flush the answerCall() microtask queue. + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1'); + expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + expect(useCallStore.getState().call?.callId).toBe('incoming-1'); + }); + + it('A2: accepted signal but call not found → RNCallKeep.endCall, no navigate', async () => { + const session = createdSessions[createdSessions.length - 1]; + session.getCallData.mockReturnValue(undefined); + + act(() => { + useCallStore.getState().setNativeAcceptedCallId('missing-1'); + }); + + await act(async () => { + emitDDPMediaSignal({ + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'missing-1' + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('missing-1'); + expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(useCallStore.getState().call).toBeNull(); + }); + + it('A3: idempotency — existing call matches callId, answerCall early-returns', async () => { + // Test-pollution guard: confirms the outer reset() actually cleared state. + expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); + + const session = createdSessions[createdSessions.length - 1]; + const existingCall = makeSyntheticCall({ callId: 'incoming-1', role: 'callee' }); + act(() => { + useCallStore.getState().setCall(existingCall); + }); + (Navigation.navigate as jest.Mock).mockClear(); + (RNCallKeep.setCurrentCallActive as jest.Mock).mockClear(); + + await act(async () => { + await mediaSessionInstance.answerCall('incoming-1'); + }); + + // getCallData is the SDK boundary — confirming it was never hit proves + // the early-return branch ran before any SDK interaction. + expect(session.getCallData).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(RNCallKeep.setCurrentCallActive as jest.Mock).not.toHaveBeenCalled(); + }); + }); + + // ── UI store contract: Hang up ──────────────────────────────────────────── + // The CallView end button wires to useCallStore.endCall (see + // app/views/CallView/components/CallButtons.tsx), NOT MediaSessionInstance.endCall. + // The latter is invoked from native CallKit "end" events. Both need coverage. + + describe('UI store contract: Hang up', () => { + it('B1: useCallStore.endCall clears store and triggers RNCallKeep.endCall', () => { + setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' }); + const { getByTestId } = render( + + + + ); + // Real press path wires listeners via setCall. + fireEvent.press(getByTestId('new-media-call-button')); + expect(useCallStore.getState().call?.callId).toBe('call-user-1'); + + act(() => { + useCallStore.getState().endCall(); + }); + + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('call-user-1'); + expect(InCallManager.stop as jest.Mock).toHaveBeenCalled(); + expect(useCallStore.getState().call).toBeNull(); + expect(useCallStore.getState().callId).toBeNull(); + }); + + it('B2: MediaSessionInstance.endCall during active state → RNCallKeep cleanup, store reset', () => { + const session = createdSessions[createdSessions.length - 1]; + const activeCall = makeSyntheticCall({ callId: 'active-1', state: 'active' }); + session.getCallData.mockReturnValue(activeCall); + + act(() => { + mediaSessionInstance.endCall('active-1'); + }); + + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('active-1'); + expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith(''); + expect(RNCallKeep.setAvailable as jest.Mock).toHaveBeenCalledWith(true); + expect(useCallStore.getState().call).toBeNull(); + }); + + it('B3: MediaSessionInstance.endCall during ringing → same cleanup (reject branch)', () => { + const session = createdSessions[createdSessions.length - 1]; + const ringingCall = makeSyntheticCall({ callId: 'ringing-1', state: 'ringing' }); + session.getCallData.mockReturnValue(ringingCall); + + act(() => { + mediaSessionInstance.endCall('ringing-1'); + }); + + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('ringing-1'); + expect(useCallStore.getState().call).toBeNull(); + }); + }); + + // ── UI store contract: In-call controls (mute / hold) ──────────────────── + + describe('UI store contract: In-call controls (mute/hold)', () => { + it('C1: toggleMute flips store isMuted; second toggle restores it', () => { + const call = makeSyntheticCall({ callId: 'ctrl-mute', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + expect(useCallStore.getState().isMuted).toBe(false); + + act(() => { + useCallStore.getState().toggleMute(); + }); + expect(useCallStore.getState().isMuted).toBe(true); + + act(() => { + useCallStore.getState().toggleMute(); + }); + expect(useCallStore.getState().isMuted).toBe(false); + }); + + it('C2: toggleHold flips store isOnHold; second toggle restores it', () => { + const call = makeSyntheticCall({ callId: 'ctrl-hold', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + expect(useCallStore.getState().isOnHold).toBe(false); + + act(() => { + useCallStore.getState().toggleHold(); + }); + expect(useCallStore.getState().isOnHold).toBe(true); + + act(() => { + useCallStore.getState().toggleHold(); + }); + expect(useCallStore.getState().isOnHold).toBe(false); + }); + + it('C3: trackStateChange emission syncs store from call participant state', () => { + const call = makeSyntheticCall({ callId: 'ctrl-track', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + expect(useCallStore.getState().isMuted).toBe(false); + expect(useCallStore.getState().remoteHeld).toBe(false); + + // Mutate the call's state the way the SDK would before dispatching the event. + // The SDK types flag these fields as readonly; tests cast through + // Record because we're standing in for the SDK here. + (call.localParticipant as unknown as Record).muted = true; + (call.remoteParticipants[0] as unknown as Record).held = true; + + act(() => { + (call.emitter as unknown as ReturnType).emit('trackStateChange'); + }); + + expect(useCallStore.getState().isMuted).toBe(true); + expect(useCallStore.getState().remoteHeld).toBe(true); + expect(useCallStore.getState().controlsVisible).toBe(true); + }); + }); + + // startCall rejection path — deferred to Phase 3. + // MediaSessionInstance.ts:151-155 does not `await` or `.catch` the SDK's + // startCall promise, so a rejection leaks as an unhandled rejection. Phase 3 + // will land the `.catch` first, then add the rejection-path integration test. }); From ea24be939938bd6f6a719f852c7dbafa30846914 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Apr 2026 11:40:33 -0300 Subject: [PATCH 10/12] =?UTF-8?q?test(voip):=20harden=20VoipCallLifecycle?= =?UTF-8?q?=20integration=20tests=20=E2=80=94=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap afterEach reset() in act() to prevent leaked Zustand set() - Strip redundant inner act() from flushMicrotasks helper - Restore jest.fn() on getUniqueId/getUniqueIdSync for spy capability - Rename F1 → E1 to fix test numbering after D-series addition - Add C4: toggleSpeaker async path via InCallManager - Tighten A2: assert console.warn for expected call-not-found branch --- .../VoipCallLifecycle.integration.test.tsx | 235 ++++++++++++------ 1 file changed, 159 insertions(+), 76 deletions(-) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index 94577136b86..b87ee506fe1 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -110,21 +110,21 @@ jest.mock('react-native-device-info', () => ({ default: { getUniqueId: jest.fn(() => 'test-device-id'), getUniqueIdSync: jest.fn(() => 'test-device-id'), - hasNotch: jest.fn(() => false), - getReadableVersion: jest.fn(() => '1.0.0'), - getBundleId: jest.fn(() => 'com.rocket.chat'), - getModel: jest.fn(() => 'iPhone'), - getSystemVersion: jest.fn(() => '14.0'), - isTablet: jest.fn(() => false) + hasNotch: () => false, + getReadableVersion: () => '1.0.0', + getBundleId: () => 'com.rocket.chat', + getModel: () => 'iPhone', + getSystemVersion: () => '14.0', + isTablet: () => false }, getUniqueId: jest.fn(() => 'test-device-id'), getUniqueIdSync: jest.fn(() => 'test-device-id'), - hasNotch: jest.fn(() => false), - getReadableVersion: jest.fn(() => '1.0.0'), - getBundleId: jest.fn(() => 'com.rocket.chat'), - getModel: jest.fn(() => 'iPhone'), - getSystemVersion: jest.fn(() => '14.0'), - isTablet: jest.fn(() => false) + hasNotch: () => false, + getReadableVersion: () => '1.0.0', + getBundleId: () => 'com.rocket.chat', + getModel: () => 'iPhone', + getSystemVersion: () => '14.0', + isTablet: () => false })); jest.mock('../../lib/native/NativeVoip', () => ({ __esModule: true, @@ -254,43 +254,20 @@ jest.mock('@rocket.chat/media-signaling', () => ({ // ─── Helpers ────────────────────────────────────────────────────────────────── -function makeIncomingCall(options: { callId?: string; role?: 'caller' | 'callee'; hidden?: boolean }): IClientMediaCall { - return { - callId: options.callId ?? 'incoming-call', - hidden: options.hidden ?? false, - state: 'ringing', - localParticipant: { - local: true, - role: options.role ?? 'callee', - muted: false, - held: false, - contact: {}, - setMuted: jest.fn(), - setHeld: jest.fn() - }, - remoteParticipants: [ - { local: false, role: options.role === 'caller' ? 'callee' : 'caller', muted: false, held: false, contact: {} } - ], - reject: jest.fn(), - hangup: jest.fn(), - sendDTMF: jest.fn(), - emitter: mockCallEmitter() as unknown as IClientMediaCall['emitter'] - } as unknown as IClientMediaCall; -} - -// Extended synthetic call with all methods the real handlers exercise: -// accept (answerCall), setMuted/setHeld (toggleMute/toggleHold), hangup/reject (endCall). -function makeSyntheticCall(overrides: { +// Unified call factory — covers all test call creation needs. +// setMuted/setHeld for controls tests; accept for answerCall; hangup/reject for endCall. +function makeCall(overrides: { callId?: string; state?: 'ringing' | 'active' | 'accepted' | 'ended' | 'none'; role?: 'caller' | 'callee'; + hidden?: boolean; remoteMuted?: boolean; remoteHeld?: boolean; }): IClientMediaCall { - const callId = overrides.callId ?? 'synthetic-call'; + const callId = overrides.callId ?? 'default-call'; return { callId, - hidden: false, + hidden: overrides.hidden ?? false, state: overrides.state ?? 'ringing', localParticipant: { local: true, @@ -335,6 +312,12 @@ function setSelectedPeer(peer: TPeerItem): void { const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; +// Flushes the microtask queue (answerCall uses async handlers). +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + // ─── Console-error handling ─────────────────────────────────────────────────── // // Replaces the previous blanket `jest.spyOn(console, 'error').mockImplementation(() => {})` @@ -415,7 +398,9 @@ describe('VoIP call lifecycle (integration)', () => { consoleWarnSpy?.mockRestore(); consoleErrorSpy = undefined; consoleWarnSpy = undefined; - mediaSessionInstance.reset(); + act(() => { + mediaSessionInstance.reset(); + }); if (unexpectedConsoleErrors.length > 0) { const joined = unexpectedConsoleErrors.join('\n - '); throw new Error(`Unexpected console.error/warn in test:\n - ${joined}`); @@ -495,7 +480,7 @@ describe('VoIP call lifecycle (integration)', () => { it('hidden call: newCall with hidden=true does not navigate or populate store', () => { const session = createdSessions[createdSessions.length - 1]; - const hiddenCall = makeIncomingCall({ callId: 'hidden-1', hidden: true, role: 'caller' }); + const hiddenCall = makeCall({ callId: 'hidden-1', hidden: true, role: 'caller' }); act(() => { session.emit('newCall', { call: hiddenCall }); @@ -507,7 +492,7 @@ describe('VoIP call lifecycle (integration)', () => { it('callee role: newCall does not navigate (incoming calls route via answerCall)', () => { const session = createdSessions[createdSessions.length - 1]; - const incomingCall = makeIncomingCall({ callId: 'incoming-1', role: 'callee' }); + const incomingCall = makeCall({ callId: 'incoming-1', role: 'callee' }); act(() => { session.emit('newCall', { call: incomingCall }); @@ -520,25 +505,7 @@ describe('VoIP call lifecycle (integration)', () => { // ── CallView render contract ────────────────────────────────────────────── it('setCall populates store and CallView renders; clearing store unmounts it', () => { - const emitter = mockCallEmitter(); - const call = { - callId: 'c-render', - state: 'active', - hidden: false, - localParticipant: { local: true, role: 'caller', muted: false, held: false, contact: {} }, - remoteParticipants: [ - { - local: false, - role: 'callee', - muted: false, - held: false, - contact: { displayName: 'Bob', username: 'bob', sipExtension: '' } - } - ], - reject: jest.fn(), - hangup: jest.fn(), - emitter: emitter as unknown as IClientMediaCall['emitter'] - } as unknown as IClientMediaCall; + const call = makeCall({ callId: 'c-render', state: 'active', role: 'caller' }); act(() => { useCallStore.getState().setCall(call); @@ -563,7 +530,7 @@ describe('VoIP call lifecycle (integration)', () => { describe('MediaSessionInstance contract: answerCall', () => { it('A1: DDP accepted signal with native pre-accept → answerCall navigates to CallView', async () => { const session = createdSessions[createdSessions.length - 1]; - const mainCall = makeSyntheticCall({ callId: 'incoming-1', role: 'callee' }); + const mainCall = makeCall({ callId: 'incoming-1', role: 'callee' }); session.getCallData.mockReturnValue(mainCall); act(() => { @@ -578,8 +545,7 @@ describe('VoIP call lifecycle (integration)', () => { callId: 'incoming-1' }); // Flush the answerCall() microtask queue. - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(); }); expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1'); @@ -602,14 +568,15 @@ describe('VoIP call lifecycle (integration)', () => { signedContractId: 'test-device-id', callId: 'missing-1' }); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(); }); expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('missing-1'); expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(useCallStore.getState().call).toBeNull(); + // Tighten: confirm the known-noise allowlist entry was actually triggered. + expect(consoleWarnSpy).toHaveBeenCalledWith('[VoIP] Call not found:', 'missing-1'); }); it('A3: idempotency — existing call matches callId, answerCall early-returns', async () => { @@ -617,7 +584,7 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); const session = createdSessions[createdSessions.length - 1]; - const existingCall = makeSyntheticCall({ callId: 'incoming-1', role: 'callee' }); + const existingCall = makeCall({ callId: 'incoming-1', role: 'callee' }); act(() => { useCallStore.getState().setCall(existingCall); }); @@ -636,11 +603,9 @@ describe('VoIP call lifecycle (integration)', () => { }); }); - // ── UI store contract: Hang up ──────────────────────────────────────────── // The CallView end button wires to useCallStore.endCall (see // app/views/CallView/components/CallButtons.tsx), NOT MediaSessionInstance.endCall. // The latter is invoked from native CallKit "end" events. Both need coverage. - describe('UI store contract: Hang up', () => { it('B1: useCallStore.endCall clears store and triggers RNCallKeep.endCall', () => { setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' }); @@ -665,7 +630,7 @@ describe('VoIP call lifecycle (integration)', () => { it('B2: MediaSessionInstance.endCall during active state → RNCallKeep cleanup, store reset', () => { const session = createdSessions[createdSessions.length - 1]; - const activeCall = makeSyntheticCall({ callId: 'active-1', state: 'active' }); + const activeCall = makeCall({ callId: 'active-1', state: 'active' }); session.getCallData.mockReturnValue(activeCall); act(() => { @@ -680,7 +645,7 @@ describe('VoIP call lifecycle (integration)', () => { it('B3: MediaSessionInstance.endCall during ringing → same cleanup (reject branch)', () => { const session = createdSessions[createdSessions.length - 1]; - const ringingCall = makeSyntheticCall({ callId: 'ringing-1', state: 'ringing' }); + const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' }); session.getCallData.mockReturnValue(ringingCall); act(() => { @@ -696,7 +661,7 @@ describe('VoIP call lifecycle (integration)', () => { describe('UI store contract: In-call controls (mute/hold)', () => { it('C1: toggleMute flips store isMuted; second toggle restores it', () => { - const call = makeSyntheticCall({ callId: 'ctrl-mute', role: 'caller', state: 'active' }); + const call = makeCall({ callId: 'ctrl-mute', role: 'caller', state: 'active' }); act(() => { useCallStore.getState().setCall(call); }); @@ -714,7 +679,7 @@ describe('VoIP call lifecycle (integration)', () => { }); it('C2: toggleHold flips store isOnHold; second toggle restores it', () => { - const call = makeSyntheticCall({ callId: 'ctrl-hold', role: 'caller', state: 'active' }); + const call = makeCall({ callId: 'ctrl-hold', role: 'caller', state: 'active' }); act(() => { useCallStore.getState().setCall(call); }); @@ -732,7 +697,7 @@ describe('VoIP call lifecycle (integration)', () => { }); it('C3: trackStateChange emission syncs store from call participant state', () => { - const call = makeSyntheticCall({ callId: 'ctrl-track', role: 'caller', state: 'active' }); + const call = makeCall({ callId: 'ctrl-track', role: 'caller', state: 'active' }); act(() => { useCallStore.getState().setCall(call); }); @@ -753,10 +718,128 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().remoteHeld).toBe(true); expect(useCallStore.getState().controlsVisible).toBe(true); }); + + it('C4: toggleSpeaker flips store isSpeakerOn via InCallManager', async () => { + const call = makeCall({ callId: 'ctrl-speaker', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + expect(useCallStore.getState().isSpeakerOn).toBe(false); + + await act(async () => { + useCallStore.getState().toggleSpeaker(); + }); + expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(true); + expect(useCallStore.getState().isSpeakerOn).toBe(true); + + await act(async () => { + useCallStore.getState().toggleSpeaker(); + }); + expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(false); + expect(useCallStore.getState().isSpeakerOn).toBe(false); + }); + }); + + // Closes the loop: render real CallView, press actual buttons, assert store flip + SDK call. + describe('CallView button wiring (UI → store → SDK)', () => { + it('D1: press mute button → participant.setMuted invoked, store flips', () => { + const call = makeCall({ callId: 'btn-mute', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + + const { getByTestId } = render( + + + + ); + + act(() => { + fireEvent.press(getByTestId('call-view-mute')); + }); + + expect(call.localParticipant.setMuted).toHaveBeenCalledWith(true); + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('D2: press hold button → participant.setHeld invoked, store flips', () => { + const call = makeCall({ callId: 'btn-hold', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + + const { getByTestId } = render( + + + + ); + + act(() => { + fireEvent.press(getByTestId('call-view-hold')); + }); + + expect(call.localParticipant.setHeld).toHaveBeenCalledWith(true); + expect(useCallStore.getState().isOnHold).toBe(true); + }); + + it('D3: press end button → call.hangup, RNCallKeep.endCall, store cleared', () => { + const call = makeCall({ callId: 'btn-end', role: 'caller', state: 'active' }); + act(() => { + useCallStore.getState().setCall(call); + }); + + const { getByTestId } = render( + + + + ); + + act(() => { + fireEvent.press(getByTestId('call-view-end')); + }); + + expect(call.hangup).toHaveBeenCalled(); + expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('btn-end'); + expect(useCallStore.getState().call).toBeNull(); + }); + }); + + // Covers the handleStateChange listener wired by setCall in useCallStore.ts:169. + // Specifically the ringing → active transition that records callStartTime and + // tells iOS CallKit to surface the call. Pure unit tests miss this cross-module handoff. + describe('Call state transitions', () => { + it('E1: stateChange ringing → active sets callStartTime + RNCallKeep.setCurrentCallActive', () => { + const call = makeCall({ callId: 'state-1', role: 'caller', state: 'ringing' }); + act(() => { + useCallStore.getState().setCall(call); + }); + expect(useCallStore.getState().callState).toBe('ringing'); + expect(useCallStore.getState().callStartTime).toBeNull(); + (RNCallKeep.setCurrentCallActive as jest.Mock).mockClear(); + + // SDK mutates the underlying call before emitting (state is readonly in types, + // but the SDK owns this object — tests cast through Record to stand in). + (call as unknown as Record).state = 'active'; + act(() => { + (call.emitter as unknown as ReturnType).emit('stateChange', 'ringing'); + }); + + expect(useCallStore.getState().callState).toBe('active'); + expect(useCallStore.getState().callStartTime).not.toBeNull(); + expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('state-1'); + }); }); // startCall rejection path — deferred to Phase 3. // MediaSessionInstance.ts:151-155 does not `await` or `.catch` the SDK's // startCall promise, so a rejection leaks as an unhandled rejection. Phase 3 // will land the `.catch` first, then add the rejection-path integration test. + // + // Native CallKit event tests (RNCallKeep.addEventListener('endCall' / 'didPerform...') + // from MediaCallEvents.ts) — also deferred. They require: + // - Platform mock to force isIOS branch (listener registration is platform-gated) + // - Isolation of the deepLinkingOpen saga dispatch (real Redux store import in + // MediaCallEvents.ts vs mockedStore in this Provider) + // - Capture seam for RNCallKeep.addEventListener handlers + // Worth a small helper module before adding the tests. }); From 71284bad216fb64f9a0f3d22e0fc1c0a8e8ee4a9 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Apr 2026 14:30:44 -0300 Subject: [PATCH 11/12] fix(voip): await async toggleSpeaker in integration test Add missing await for toggleSpeaker() calls inside act() to satisfy ESLint require-await rule. --- .../NewMediaCall/VoipCallLifecycle.integration.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index b87ee506fe1..7f465fc7baf 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -727,13 +727,13 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().isSpeakerOn).toBe(false); await act(async () => { - useCallStore.getState().toggleSpeaker(); + await useCallStore.getState().toggleSpeaker(); }); expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(true); expect(useCallStore.getState().isSpeakerOn).toBe(true); await act(async () => { - useCallStore.getState().toggleSpeaker(); + await useCallStore.getState().toggleSpeaker(); }); expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(false); expect(useCallStore.getState().isSpeakerOn).toBe(false); From 2e38790c40b8dfc9652f6f72181cf89ac355e57f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 16 Apr 2026 14:36:47 -0300 Subject: [PATCH 12/12] fix(voip): add missing DeviceInfo mock methods in integration test Add getVersion and getBuildNumber to react-native-device-info mock to fix test suite initialization failure in CI. --- .../NewMediaCall/VoipCallLifecycle.integration.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index 7f465fc7baf..091b88c6d59 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -112,6 +112,8 @@ jest.mock('react-native-device-info', () => ({ getUniqueIdSync: jest.fn(() => 'test-device-id'), hasNotch: () => false, getReadableVersion: () => '1.0.0', + getVersion: () => '1.0.0', + getBuildNumber: () => '1', getBundleId: () => 'com.rocket.chat', getModel: () => 'iPhone', getSystemVersion: () => '14.0', @@ -121,6 +123,8 @@ jest.mock('react-native-device-info', () => ({ getUniqueIdSync: jest.fn(() => 'test-device-id'), hasNotch: () => false, getReadableVersion: () => '1.0.0', + getVersion: () => '1.0.0', + getBuildNumber: () => '1', getBundleId: () => 'com.rocket.chat', getModel: () => 'iPhone', getSystemVersion: () => '14.0',