Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { setCurrentScreen } from './lib/methods/helpers/log';
import { themes } from './lib/constants/colors';
import { emitter } from './lib/methods/helpers';
import MediaCallHeader from './containers/MediaCallHeader/MediaCallHeader';
import { CallNavRouter } from './lib/services/voip/CallNavRouter';

const createStackNavigator = createNativeStackNavigator;

Expand All @@ -36,6 +37,11 @@ const Stack = createStackNavigator<StackParamList>();
const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => {
const { theme } = useContext(ThemeContext);

useEffect(() => {
// Mount CallNavRouter once — it subscribes to CallLifecycle after NavigationContainer is ready.
CallNavRouter.mount();
}, []);

useEffect(() => {
if (root) {
const state = Navigation.navigationRef.current?.getRootState();
Expand Down
54 changes: 39 additions & 15 deletions app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => {
const { call } = useCallStore.getState();
expect(call?.callId).toBe('call-user-1');

// Firing 'ended' triggers voipNative cleanup and navigation back via real handlers.
act(() => {
// Firing 'ended' triggers CallLifecycle teardown via the handleEnded listener.
// Navigation.back() is now handled by CallNavRouter (not wired in this integration test).
// We verify the teardown sequence runs: store cleared, native end issued.
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
(call!.emitter as unknown as ReturnType<typeof mockCallEmitter>).emit('ended');
await Promise.resolve();
});
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
expect(Navigation.back).toHaveBeenCalled();
// Navigation.back() is now owned by CallNavRouter after callEnded emits.
// In this test environment, CallNavRouter is not mounted, so we assert the store cleared instead.
expect(useCallStore.getState().call).toBeNull();
});

it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', async () => {
Expand Down Expand Up @@ -635,37 +641,53 @@ describe('VoIP call lifecycle (integration)', () => {
await act(() => Promise.resolve());
expect(useCallStore.getState().call?.callId).toBe('call-user-1');

act(() => {
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
useCallStore.getState().endCall();
await Promise.resolve();
});

expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
// stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(),
// which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop.
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'stopAudio' });
expect(useCallStore.getState().call).toBeNull();
expect(useCallStore.getState().callId).toBeNull();
});

it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', () => {
const session = createdSessions[createdSessions.length - 1];
it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', async () => {
// endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the
// active call from useCallStore, so the call must be set there first.
const activeCall = makeCall({ callId: 'active-1', state: 'active' });
session.getCallData.mockReturnValue(activeCall);

act(() => {
useCallStore.getState().setCall(activeCall);
});

// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
mediaSessionInstance.endCall('active-1');
await Promise.resolve();
});

// CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep).
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'active-1' });
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: '' });
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'active-1' });
expect(useCallStore.getState().call).toBeNull();
});

it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', () => {
const session = createdSessions[createdSessions.length - 1];
const ringingCall = makeCall({ callId: 'ringing-1' });
session.getCallData.mockReturnValue(ringingCall);

it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', async () => {
// CallLifecycle reads the active call from useCallStore to decide reject vs hangup.
// The ringing call must be in the store for reject() to be called.
const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' });
act(() => {
useCallStore.getState().setCall(ringingCall);
});

// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
mediaSessionInstance.endCall('ringing-1');
await Promise.resolve();
});

expect(ringingCall.reject).toHaveBeenCalled();
Expand Down Expand Up @@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => {
expect(useCallStore.getState().isOnHold).toBe(true);
});

it('D3: press end button → call.hangup, voipNative.call.end, store cleared', () => {
it('D3: press end button → call.hangup, voipNative.call.end, store cleared', async () => {
const call = makeCall({ callId: 'btn-end', role: 'caller', state: 'active' });
act(() => {
useCallStore.getState().setCall(call);
Expand All @@ -812,8 +834,10 @@ describe('VoIP call lifecycle (integration)', () => {
</Wrapper>
);

act(() => {
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
fireEvent.press(getByTestId('call-view-end'));
await Promise.resolve();
});

expect(call.hangup).toHaveBeenCalled();
Expand Down
Loading