@@ -448,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => {
448448 const { call } = useCallStore . getState ( ) ;
449449 expect ( call ?. callId ) . toBe ( 'call-user-1' ) ;
450450
451- // Firing 'ended' triggers voipNative cleanup and navigation back via real handlers.
452- act ( ( ) => {
451+ // Firing 'ended' triggers CallLifecycle teardown via the handleEnded listener.
452+ // Navigation.back() is now handled by CallNavRouter (not wired in this integration test).
453+ // We verify the teardown sequence runs: store cleared, native end issued.
454+ // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
455+ await act ( async ( ) => {
453456 ( call ! . emitter as unknown as ReturnType < typeof mockCallEmitter > ) . emit ( 'ended' ) ;
457+ await Promise . resolve ( ) ;
454458 } ) ;
455459 expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'end' , callUuid : 'call-user-1' } ) ;
456- expect ( Navigation . back ) . toHaveBeenCalled ( ) ;
460+ // Navigation.back() is now owned by CallNavRouter after callEnded emits.
461+ // In this test environment, CallNavRouter is not mounted, so we assert the store cleared instead.
462+ expect ( useCallStore . getState ( ) . call ) . toBeNull ( ) ;
457463 } ) ;
458464
459465 it ( 'SIP peer: press Call → startCall(sip, number) → navigates to CallView' , async ( ) => {
@@ -635,37 +641,53 @@ describe('VoIP call lifecycle (integration)', () => {
635641 await act ( ( ) => Promise . resolve ( ) ) ;
636642 expect ( useCallStore . getState ( ) . call ?. callId ) . toBe ( 'call-user-1' ) ;
637643
638- act ( ( ) => {
644+ // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
645+ await act ( async ( ) => {
639646 useCallStore . getState ( ) . endCall ( ) ;
647+ await Promise . resolve ( ) ;
640648 } ) ;
641649
642650 expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'end' , callUuid : 'call-user-1' } ) ;
651+ // stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(),
652+ // which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop.
643653 expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'stopAudio' } ) ;
644654 expect ( useCallStore . getState ( ) . call ) . toBeNull ( ) ;
645655 expect ( useCallStore . getState ( ) . callId ) . toBeNull ( ) ;
646656 } ) ;
647657
648- it ( 'B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset' , ( ) => {
649- const session = createdSessions [ createdSessions . length - 1 ] ;
658+ it ( 'B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset' , async ( ) => {
659+ // endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the
660+ // active call from useCallStore, so the call must be set there first.
650661 const activeCall = makeCall ( { callId : 'active-1' , state : 'active' } ) ;
651- session . getCallData . mockReturnValue ( activeCall ) ;
652-
653662 act ( ( ) => {
663+ useCallStore . getState ( ) . setCall ( activeCall ) ;
664+ } ) ;
665+
666+ // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
667+ await act ( async ( ) => {
654668 mediaSessionInstance . endCall ( 'active-1' ) ;
669+ await Promise . resolve ( ) ;
655670 } ) ;
656671
672+ // CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep).
657673 expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'end' , callUuid : 'active-1' } ) ;
674+ expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'markActive' , callUuid : '' } ) ;
658675 expect ( ( voipNative as InMemoryVoipNative ) . recorded ) . toContainEqual ( { cmd : 'markAvailable' , callUuid : 'active-1' } ) ;
659676 expect ( useCallStore . getState ( ) . call ) . toBeNull ( ) ;
660677 } ) ;
661678
662- it ( 'B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup' , ( ) => {
663- const session = createdSessions [ createdSessions . length - 1 ] ;
664- const ringingCall = makeCall ( { callId : 'ringing-1' } ) ;
665- session . getCallData . mockReturnValue ( ringingCall ) ;
666-
679+ it ( 'B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup' , async ( ) => {
680+ // CallLifecycle reads the active call from useCallStore to decide reject vs hangup.
681+ // The ringing call must be in the store for reject() to be called.
682+ const ringingCall = makeCall ( { callId : 'ringing-1' , state : 'ringing' } ) ;
667683 act ( ( ) => {
684+ useCallStore . getState ( ) . setCall ( ringingCall ) ;
685+ } ) ;
686+
687+ // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
688+ await act ( async ( ) => {
668689 mediaSessionInstance . endCall ( 'ringing-1' ) ;
690+ await Promise . resolve ( ) ;
669691 } ) ;
670692
671693 expect ( ringingCall . reject ) . toHaveBeenCalled ( ) ;
@@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => {
800822 expect ( useCallStore . getState ( ) . isOnHold ) . toBe ( true ) ;
801823 } ) ;
802824
803- it ( 'D3: press end button → call.hangup, voipNative.call.end, store cleared' , ( ) => {
825+ it ( 'D3: press end button → call.hangup, voipNative.call.end, store cleared' , async ( ) => {
804826 const call = makeCall ( { callId : 'btn-end' , role : 'caller' , state : 'active' } ) ;
805827 act ( ( ) => {
806828 useCallStore . getState ( ) . setCall ( call ) ;
@@ -812,8 +834,10 @@ describe('VoIP call lifecycle (integration)', () => {
812834 </ Wrapper >
813835 ) ;
814836
815- act ( ( ) => {
837+ // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
838+ await act ( async ( ) => {
816839 fireEvent . press ( getByTestId ( 'call-view-end' ) ) ;
840+ await Promise . resolve ( ) ;
817841 } ) ;
818842
819843 expect ( call . hangup ) . toHaveBeenCalled ( ) ;
0 commit comments