Skip to main content

active_call/call/
sip.rs

1use crate::call::active_call::ActiveCallStateRef;
2use crate::callrecord::CallRecordHangupReason;
3use crate::event::EventSender;
4use crate::media::TrackId;
5use crate::media::stream::MediaStream;
6use crate::useragent::invitation::PendingDialog;
7use anyhow::Result;
8use chrono::Utc;
9use rsipstack::dialog::DialogId;
10use rsipstack::dialog::dialog::{
11    Dialog, DialogState, DialogStateReceiver, DialogStateSender, TerminatedReason,
12};
13use rsipstack::dialog::dialog_layer::DialogLayer;
14use rsipstack::dialog::invitation::InviteOption;
15use rsipstack::rsip_ext::RsipResponseExt;
16use std::collections::HashMap;
17use std::sync::Arc;
18use tokio_util::sync::CancellationToken;
19use tracing::{info, warn};
20
21pub struct DialogStateReceiverGuard {
22    pub(super) dialog_layer: Arc<DialogLayer>,
23    pub(super) receiver: DialogStateReceiver,
24    pub(super) dialog_id: Option<DialogId>,
25    pub(super) hangup_headers: Option<Vec<rsip::Header>>,
26}
27
28impl DialogStateReceiverGuard {
29    pub fn new(
30        dialog_layer: Arc<DialogLayer>,
31        receiver: DialogStateReceiver,
32        hangup_headers: Option<Vec<rsip::Header>>,
33    ) -> Self {
34        Self {
35            dialog_layer,
36            receiver,
37            dialog_id: None,
38            hangup_headers,
39        }
40    }
41    pub async fn recv(&mut self) -> Option<DialogState> {
42        let state = self.receiver.recv().await;
43        if let Some(ref s) = state {
44            self.dialog_id = Some(s.id().clone());
45        }
46        state
47    }
48
49    fn take_dialog(&mut self) -> Option<Dialog> {
50        let id = match self.dialog_id.take() {
51            Some(id) => id,
52            None => return None,
53        };
54
55        match self.dialog_layer.get_dialog(&id) {
56            Some(dialog) => {
57                info!(%id, "dialog removed on  drop");
58                self.dialog_layer.remove_dialog(&id);
59                return Some(dialog);
60            }
61            _ => {}
62        }
63        None
64    }
65
66    pub async fn drop_async(&mut self) {
67        if let Some(dialog) = self.take_dialog() {
68            if let Err(e) = dialog.hangup_with_headers(self.hangup_headers.take()).await {
69                warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
70            }
71        }
72    }
73}
74
75impl Drop for DialogStateReceiverGuard {
76    fn drop(&mut self) {
77        if let Some(dialog) = self.take_dialog() {
78            crate::spawn(async move {
79                if let Err(e) = dialog.hangup().await {
80                    warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
81                }
82            });
83        }
84    }
85}
86
87pub(super) struct InviteDialogStates {
88    pub is_client: bool,
89    pub session_id: String,
90    pub track_id: TrackId,
91    pub cancel_token: CancellationToken,
92    pub event_sender: EventSender,
93    pub call_state: ActiveCallStateRef,
94    pub media_stream: Arc<MediaStream>,
95    pub terminated_reason: Option<TerminatedReason>,
96    pub has_early_media: bool,
97}
98
99impl InviteDialogStates {
100    pub(super) fn on_terminated(&mut self) {
101        let mut call_state_ref = match self.call_state.try_write() {
102            Ok(cs) => cs,
103            Err(_) => {
104                return;
105            }
106        };
107        let reason = &self.terminated_reason;
108        call_state_ref.last_status_code = match reason {
109            Some(TerminatedReason::UacCancel) => 487,
110            Some(TerminatedReason::UacBye) => 200,
111            Some(TerminatedReason::UacBusy) => 486,
112            Some(TerminatedReason::UasBye) => 200,
113            Some(TerminatedReason::UasBusy) => 486,
114            Some(TerminatedReason::UasDecline) => 603,
115            Some(TerminatedReason::UacOther(code)) => code.code(),
116            Some(TerminatedReason::UasOther(code)) => code.code(),
117            _ => 500, // Default to internal server error
118        };
119
120        if call_state_ref.hangup_reason.is_none() {
121            call_state_ref.hangup_reason.replace(match reason {
122                Some(TerminatedReason::UacCancel) => CallRecordHangupReason::Canceled,
123                Some(TerminatedReason::UacBye) | Some(TerminatedReason::UacBusy) => {
124                    CallRecordHangupReason::ByCaller
125                }
126                Some(TerminatedReason::UasBye) | Some(TerminatedReason::UasBusy) => {
127                    CallRecordHangupReason::ByCallee
128                }
129                Some(TerminatedReason::UasDecline) => CallRecordHangupReason::ByCallee,
130                Some(TerminatedReason::UacOther(_)) => CallRecordHangupReason::ByCaller,
131                Some(TerminatedReason::UasOther(_)) => CallRecordHangupReason::ByCallee,
132                _ => CallRecordHangupReason::BySystem,
133            });
134        };
135        let initiator = match reason {
136            Some(TerminatedReason::UacCancel) => "caller".to_string(),
137            Some(TerminatedReason::UacBye) | Some(TerminatedReason::UacBusy) => {
138                "caller".to_string()
139            }
140            Some(TerminatedReason::UasBye)
141            | Some(TerminatedReason::UasBusy)
142            | Some(TerminatedReason::UasDecline) => "callee".to_string(),
143            _ => "system".to_string(),
144        };
145        self.event_sender
146            .send(crate::event::SessionEvent::TrackEnd {
147                track_id: self.track_id.clone(),
148                timestamp: crate::media::get_timestamp(),
149                duration: call_state_ref
150                    .answer_time
151                    .map(|t| (Utc::now() - t).num_milliseconds())
152                    .unwrap_or_default() as u64,
153                ssrc: call_state_ref.ssrc,
154                play_id: None,
155            })
156            .ok();
157        let hangup_event =
158            call_state_ref.build_hangup_event(self.track_id.clone(), Some(initiator));
159        self.event_sender.send(hangup_event).ok();
160    }
161}
162
163impl Drop for InviteDialogStates {
164    fn drop(&mut self) {
165        self.on_terminated();
166        self.cancel_token.cancel();
167    }
168}
169
170impl DialogStateReceiverGuard {
171    pub(self) async fn dialog_event_loop(&mut self, states: &mut InviteDialogStates) -> Result<()> {
172        while let Some(event) = self.recv().await {
173            match event {
174                DialogState::Calling(dialog_id) => {
175                    info!(session_id=states.session_id, %dialog_id, "dialog calling");
176                    states.call_state.write().await.session_id = dialog_id.to_string();
177                }
178                DialogState::Trying(_) => {}
179                DialogState::Early(dialog_id, resp) => {
180                    let code = resp.status_code.code();
181                    let body = resp.body();
182                    let answer = String::from_utf8_lossy(body);
183                    let has_sdp = !answer.is_empty();
184                    info!(session_id=states.session_id, %dialog_id, has_sdp=%has_sdp, "dialog early ({}): \n{}", code, answer);
185
186                    {
187                        let mut cs = states.call_state.write().await;
188                        if cs.ring_time.is_none() {
189                            cs.ring_time.replace(Utc::now());
190                        }
191                        cs.last_status_code = code;
192                    }
193
194                    if !states.is_client {
195                        continue;
196                    }
197
198                    let refer = states.call_state.read().await.is_refer;
199
200                    states
201                        .event_sender
202                        .send(crate::event::SessionEvent::Ringing {
203                            track_id: states.track_id.clone(),
204                            timestamp: crate::media::get_timestamp(),
205                            early_media: has_sdp,
206                            refer: Some(refer),
207                        })?;
208
209                    if has_sdp {
210                        states.has_early_media = true;
211                        {
212                            let mut cs = states.call_state.write().await;
213                            if cs.answer.is_none() {
214                                cs.answer = Some(answer.to_string());
215                            }
216                        }
217                        states
218                            .media_stream
219                            .update_remote_description(&states.track_id, &answer.to_string())
220                            .await?;
221                    }
222                }
223                DialogState::Confirmed(dialog_id, msg) => {
224                    info!(session_id=states.session_id, %dialog_id, has_early_media=%states.has_early_media, "dialog confirmed");
225                    {
226                        let mut cs = states.call_state.write().await;
227                        cs.session_id = dialog_id.to_string();
228                        cs.answer_time.replace(Utc::now());
229                        cs.last_status_code = 200;
230                    }
231                    if states.is_client {
232                        let answer = String::from_utf8_lossy(msg.body());
233                        let answer = answer.trim();
234                        if !answer.is_empty() {
235                            if states.has_early_media {
236                                info!(
237                                    session_id = states.session_id,
238                                    "updating remote description with final answer after early media (force=true)"
239                                );
240                                // Force update when transitioning from early media (183) to confirmed (200 OK)
241                                // This ensures media parameters are properly updated even if SDP appears similar
242                                if let Err(e) = states
243                                    .media_stream
244                                    .update_remote_description_force(
245                                        &states.track_id,
246                                        &answer.to_string(),
247                                    )
248                                    .await
249                                {
250                                    tracing::warn!(
251                                        session_id = states.session_id,
252                                        "failed to force update remote description on confirmed: {}",
253                                        e
254                                    );
255                                }
256                            } else {
257                                if let Err(e) = states
258                                    .media_stream
259                                    .update_remote_description(
260                                        &states.track_id,
261                                        &answer.to_string(),
262                                    )
263                                    .await
264                                {
265                                    tracing::warn!(
266                                        session_id = states.session_id,
267                                        "failed to update remote description on confirmed: {}",
268                                        e
269                                    );
270                                }
271                            }
272                        }
273                    }
274                }
275                DialogState::Info(dialog_id, req, tx_handle) => {
276                    let body_str = String::from_utf8_lossy(req.body());
277                    info!(session_id=states.session_id, %dialog_id, body=%body_str, "dialog info received");
278                    if body_str.starts_with("Signal=") {
279                        let digit = body_str.trim_start_matches("Signal=").chars().next();
280                        if let Some(digit) = digit {
281                            states.event_sender.send(crate::event::SessionEvent::Dtmf {
282                                track_id: states.track_id.clone(),
283                                timestamp: crate::media::get_timestamp(),
284                                digit: digit.to_string(),
285                            })?;
286                        }
287                    }
288                    tx_handle.reply(rsip::StatusCode::OK).await.ok();
289                }
290                DialogState::Updated(dialog_id, _req, tx_handle) => {
291                    info!(session_id = states.session_id, %dialog_id, "dialog update received");
292                    let mut answer_sdp = None;
293                    if let Some(sdp_body) = _req.body().get(..) {
294                        let sdp_str = String::from_utf8_lossy(sdp_body);
295                        if !sdp_str.is_empty()
296                            && (_req.method == rsip::Method::Invite
297                                || _req.method == rsip::Method::Update)
298                        {
299                            info!(session_id=states.session_id, %dialog_id, method=%_req.method, "handling re-invite/update offer");
300
301                            // Detect hold state from SDP
302                            let is_on_hold =
303                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
304                            info!(session_id=states.session_id, %dialog_id, is_on_hold=%is_on_hold, "detected hold state from re-invite SDP");
305
306                            // Update media stream hold state
307                            if is_on_hold {
308                                states
309                                    .media_stream
310                                    .hold_track(Some(states.track_id.clone()))
311                                    .await;
312                            } else {
313                                states
314                                    .media_stream
315                                    .resume_track(Some(states.track_id.clone()))
316                                    .await;
317                            }
318
319                            // Emit hold event
320                            states
321                                .event_sender
322                                .send(crate::event::SessionEvent::Hold {
323                                    track_id: states.track_id.clone(),
324                                    timestamp: crate::media::get_timestamp(),
325                                    on_hold: is_on_hold,
326                                })
327                                .ok();
328
329                            match states
330                                .media_stream
331                                .handshake(&states.track_id, sdp_str.to_string(), None)
332                                .await
333                            {
334                                Ok(sdp) => answer_sdp = Some(sdp),
335                                Err(e) => {
336                                    warn!(
337                                        session_id = states.session_id,
338                                        "failed to handle re-invite: {}", e
339                                    );
340                                }
341                            }
342                        } else {
343                            info!(session_id=states.session_id, %dialog_id, "updating remote description:\n{}", sdp_str);
344
345                            // Also check hold state for non-INVITE/UPDATE messages with SDP
346                            let is_on_hold =
347                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
348                            if is_on_hold {
349                                states
350                                    .media_stream
351                                    .hold_track(Some(states.track_id.clone()))
352                                    .await;
353                                states
354                                    .event_sender
355                                    .send(crate::event::SessionEvent::Hold {
356                                        track_id: states.track_id.clone(),
357                                        timestamp: crate::media::get_timestamp(),
358                                        on_hold: true,
359                                    })
360                                    .ok();
361                            } else {
362                                states
363                                    .media_stream
364                                    .resume_track(Some(states.track_id.clone()))
365                                    .await;
366                                states
367                                    .event_sender
368                                    .send(crate::event::SessionEvent::Hold {
369                                        track_id: states.track_id.clone(),
370                                        timestamp: crate::media::get_timestamp(),
371                                        on_hold: false,
372                                    })
373                                    .ok();
374                            }
375
376                            states
377                                .media_stream
378                                .update_remote_description(&states.track_id, &sdp_str.to_string())
379                                .await?;
380                        }
381                    }
382
383                    if let Some(sdp) = answer_sdp {
384                        tx_handle
385                            .respond(
386                                rsip::StatusCode::OK,
387                                Some(vec![rsip::Header::ContentType(
388                                    "application/sdp".to_string().into(),
389                                )]),
390                                Some(sdp.into()),
391                            )
392                            .await
393                            .ok();
394                    } else {
395                        tx_handle.reply(rsip::StatusCode::OK).await.ok();
396                    }
397                }
398                DialogState::Options(dialog_id, _req, tx_handle) => {
399                    info!(session_id = states.session_id, %dialog_id, "dialog options received");
400                    tx_handle.reply(rsip::StatusCode::OK).await.ok();
401                }
402                DialogState::Terminated(dialog_id, reason) => {
403                    info!(
404                        session_id = states.session_id,
405                        ?dialog_id,
406                        ?reason,
407                        "dialog terminated"
408                    );
409                    states.terminated_reason = Some(reason.clone());
410                    return Ok(());
411                }
412                other_state => {
413                    info!(
414                        session_id = states.session_id,
415                        %other_state,
416                        "dialog received other state"
417                    );
418                }
419            }
420        }
421        Ok(())
422    }
423
424    pub(super) async fn process_dialog(&mut self, mut states: InviteDialogStates) {
425        let token = states.cancel_token.clone();
426        tokio::select! {
427            _ = token.cancelled() => {
428                states.terminated_reason = Some(TerminatedReason::UacCancel);
429            }
430            _ = self.dialog_event_loop(&mut states) => {}
431        };
432
433        // Update hangup headers from ActiveCallState if available
434        {
435            let state = states.call_state.read().await;
436            if let Some(extras) = &state.extras {
437                if let Some(h_val) = extras.get("_hangup_headers") {
438                    if let Ok(headers_map) =
439                        serde_json::from_value::<HashMap<String, String>>(h_val.clone())
440                    {
441                        let mut headers = Vec::new();
442                        for (k, v) in headers_map {
443                            headers.push(rsip::Header::Other(k.into(), v.into()));
444                        }
445                        if !headers.is_empty() {
446                            if let Some(existing) = &mut self.hangup_headers {
447                                existing.extend(headers);
448                            } else {
449                                self.hangup_headers = Some(headers);
450                            }
451                        }
452                    }
453                }
454            }
455        }
456
457        self.drop_async().await;
458    }
459}
460
461#[derive(Clone)]
462pub struct Invitation {
463    pub dialog_layer: Arc<DialogLayer>,
464    pub pending_dialogs: Arc<std::sync::Mutex<HashMap<DialogId, PendingDialog>>>,
465}
466
467impl Invitation {
468    pub fn new(dialog_layer: Arc<DialogLayer>) -> Self {
469        Self {
470            dialog_layer,
471            pending_dialogs: Arc::new(std::sync::Mutex::new(HashMap::new())),
472        }
473    }
474
475    pub fn add_pending(&self, dialog_id: DialogId, pending: PendingDialog) {
476        self.pending_dialogs
477            .lock()
478            .map(|mut ps| ps.insert(dialog_id, pending))
479            .ok();
480    }
481
482    pub fn get_pending_call(&self, dialog_id: &DialogId) -> Option<PendingDialog> {
483        self.pending_dialogs
484            .lock()
485            .ok()
486            .and_then(|mut ps| ps.remove(dialog_id))
487    }
488
489    pub fn has_pending_call(&self, dialog_id: &DialogId) -> bool {
490        self.pending_dialogs
491            .lock()
492            .ok()
493            .map(|ps| ps.contains_key(dialog_id))
494            .unwrap_or(false)
495    }
496
497    pub fn find_dialog_id_by_session_id(&self, session_id: &str) -> Option<DialogId> {
498        self.pending_dialogs.lock().ok().and_then(|ps| {
499            ps.iter()
500                .find(|(id, _)| id.to_string() == session_id)
501                .map(|(id, _)| id.clone())
502        })
503    }
504
505    pub async fn hangup(
506        &self,
507        dialog_id: DialogId,
508        code: Option<rsip::StatusCode>,
509        reason: Option<String>,
510    ) -> Result<()> {
511        if let Some(call) = self.get_pending_call(&dialog_id) {
512            call.dialog.reject(code, reason).ok();
513            call.token.cancel();
514        }
515        match self.dialog_layer.get_dialog(&dialog_id) {
516            Some(dialog) => {
517                self.dialog_layer.remove_dialog(&dialog_id);
518                dialog.hangup().await.ok();
519            }
520            None => {}
521        }
522        Ok(())
523    }
524
525    pub async fn reject(&self, dialog_id: DialogId) -> Result<()> {
526        if let Some(call) = self.get_pending_call(&dialog_id) {
527            call.dialog.reject(None, None).ok();
528            call.token.cancel();
529        }
530        match self.dialog_layer.get_dialog(&dialog_id) {
531            Some(dialog) => {
532                self.dialog_layer.remove_dialog(&dialog_id);
533                dialog.hangup().await.ok();
534            }
535            None => {}
536        }
537        Ok(())
538    }
539
540    pub async fn invite(
541        &self,
542        invite_option: InviteOption,
543        state_sender: DialogStateSender,
544    ) -> Result<(DialogId, Option<Vec<u8>>), rsipstack::Error> {
545        let (dialog, resp) = self
546            .dialog_layer
547            .do_invite(invite_option, state_sender)
548            .await?;
549
550        let offer = match resp {
551            Some(resp) => match resp.status_code.kind() {
552                rsip::StatusCodeKind::Successful => {
553                    let offer = resp.body.clone();
554                    Some(offer)
555                }
556                _ => {
557                    let reason = resp
558                        .reason_phrase()
559                        .unwrap_or(&resp.status_code.to_string())
560                        .to_string();
561                    return Err(rsipstack::Error::DialogError(
562                        reason,
563                        dialog.id(),
564                        resp.status_code,
565                    ));
566                }
567            },
568            None => {
569                return Err(rsipstack::Error::DialogError(
570                    "no response received".to_string(),
571                    dialog.id(),
572                    rsip::StatusCode::NotAcceptableHere,
573                ));
574            }
575        };
576        Ok((dialog.id(), offer))
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::call::active_call::ActiveCallState;
584    use crate::media::stream::MediaStreamBuilder;
585    use std::sync::Arc;
586    use tokio::sync::RwLock;
587    use tokio_util::sync::CancellationToken;
588
589    // SDP used to simulate an early-media 183 Session Progress response.
590    const EARLY_MEDIA_SDP: &str = "v=0\r\n\
591        o=- 1000 1 IN IP4 192.168.1.100\r\n\
592        s=SIP Call\r\n\
593        t=0 0\r\n\
594        m=audio 10000 RTP/AVP 0\r\n\
595        c=IN IP4 192.168.1.100\r\n\
596        a=rtpmap:0 PCMU/8000\r\n\
597        a=sendrecv\r\n";
598
599    fn make_response_with_body(body: Vec<u8>) -> rsip::Response {
600        let mut resp = rsip::Response::default();
601        resp.body = body;
602        resp
603    }
604
605    /// Verify that when a 183 Session Progress with SDP arrives (`DialogState::Early`),
606    /// the early SDP is stored in `call_state.answer` so it can serve as a fallback
607    /// when the final 200 OK has an empty body.
608    #[tokio::test]
609    async fn test_early_sdp_stored_in_call_state() {
610        let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
611        let media_stream = Arc::new(
612            MediaStreamBuilder::new(event_tx.clone())
613                .with_id("test-stream".to_string())
614                .build(),
615        );
616        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
617        let cancel_token = CancellationToken::new();
618
619        let mut states = InviteDialogStates {
620            is_client: true,
621            session_id: "test-session".to_string(),
622            track_id: "test-track".to_string(),
623            cancel_token: cancel_token.clone(),
624            event_sender: event_tx.clone(),
625            call_state: call_state.clone(),
626            media_stream: media_stream.clone(),
627            terminated_reason: None,
628            has_early_media: false,
629        };
630
631        // Simulate DialogState::Early with SDP body (183 Session Progress)
632        let early_resp = make_response_with_body(EARLY_MEDIA_SDP.as_bytes().to_vec());
633
634        // Manually execute the Early branch logic (same as dialog_event_loop)
635        let body = early_resp.body();
636        let answer = String::from_utf8_lossy(body);
637        let has_sdp = !answer.is_empty();
638        if states.is_client && has_sdp {
639            states.has_early_media = true;
640            {
641                let mut cs = states.call_state.write().await;
642                if cs.answer.is_none() {
643                    cs.answer = Some(answer.to_string());
644                }
645            }
646            // (update_remote_description skipped — no real RTC peer)
647        }
648
649        // Assert: early SDP is stored in call_state.answer
650        {
651            let cs = call_state.read().await;
652            assert!(
653                cs.answer.is_some(),
654                "call_state.answer should be set after 183 with SDP"
655            );
656            assert_eq!(
657                cs.answer.as_deref().unwrap(),
658                EARLY_MEDIA_SDP,
659                "call_state.answer should contain the early SDP"
660            );
661        }
662        assert!(states.has_early_media, "has_early_media should be true");
663    }
664
665    /// Verify that when a 200 OK arrives with an empty body after early media has been
666    /// negotiated, `call_state.answer` retains the early SDP (not overwritten with "").
667    ///
668    /// This is the regression test for the bug where a late 200 OK with empty body would
669    /// cause `SessionEvent::Answer { sdp: "" }` to be emitted, making the answer event
670    /// appear as if no SDP was negotiated.
671    #[tokio::test]
672    async fn test_confirmed_empty_body_keeps_early_sdp() {
673        let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
674        let media_stream = Arc::new(
675            MediaStreamBuilder::new(event_tx.clone())
676                .with_id("test-stream-2".to_string())
677                .build(),
678        );
679        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
680        let cancel_token = CancellationToken::new();
681
682        let mut states = InviteDialogStates {
683            is_client: true,
684            session_id: "test-session-2".to_string(),
685            track_id: "test-track-2".to_string(),
686            cancel_token: cancel_token.clone(),
687            event_sender: event_tx.clone(),
688            call_state: call_state.clone(),
689            media_stream: media_stream.clone(),
690            terminated_reason: None,
691            has_early_media: false,
692        };
693
694        // Step 1: simulate 183 with SDP → set has_early_media and cs.answer
695        {
696            let answer_str = EARLY_MEDIA_SDP.to_string();
697            states.has_early_media = true;
698            let mut cs = states.call_state.write().await;
699            if cs.answer.is_none() {
700                cs.answer = Some(answer_str);
701            }
702        }
703
704        // Step 2: simulate 200 OK with empty body (Confirmed handler logic)
705        let confirmed_resp = make_response_with_body(vec![]); // empty body
706        {
707            let mut cs = states.call_state.write().await;
708            cs.answer_time.replace(chrono::Utc::now());
709            cs.last_status_code = 200;
710        }
711        // The Confirmed handler in dialog_event_loop only calls update_remote_description
712        // when body is non-empty; it does NOT overwrite cs.answer.
713        let body = confirmed_resp.body();
714        let answer = String::from_utf8_lossy(body);
715        let answer_trimmed = answer.trim();
716        // Replicate Confirmed handler: only act on non-empty body
717        if states.is_client && !answer_trimmed.is_empty() {
718            // (Would call update_remote_description or update_remote_description_force)
719            // This branch should NOT execute for empty-body 200 OK
720            panic!("Confirmed handler should not update SDP for empty body");
721        }
722
723        // Assert: call_state.answer still holds the early SDP
724        {
725            let cs = call_state.read().await;
726            assert!(
727                cs.answer.is_some(),
728                "call_state.answer must not be None after 200 OK with empty body"
729            );
730            let stored_answer = cs.answer.as_deref().unwrap();
731            assert!(
732                !stored_answer.is_empty(),
733                "call_state.answer must not be empty after 200 OK with empty body"
734            );
735            assert_eq!(
736                stored_answer, EARLY_MEDIA_SDP,
737                "call_state.answer should still be the early SDP after 200 OK with empty body"
738            );
739        }
740    }
741
742    /// Verify that `create_outgoing_sip_track`'s fallback logic works:
743    /// when the 200 OK body is empty but `call_state.answer` has the early SDP,
744    /// the fallback path is taken and the early SDP is returned (not an empty string).
745    ///
746    /// This test directly validates the fix in `create_outgoing_sip_track` by
747    /// simulating the state that would exist after a 183+early-media exchange.
748    #[tokio::test]
749    async fn test_answer_fallback_to_early_sdp_when_200ok_empty() {
750        // Set up call state as it would be after early media (183 with SDP) was processed
751        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
752
753        // Simulate what the Early (183) handler does: store the early SDP in cs.answer
754        {
755            let mut cs = call_state.write().await;
756            cs.answer = Some(EARLY_MEDIA_SDP.to_string());
757        }
758
759        // Simulate what create_outgoing_sip_track does when 200 OK has empty body:
760        //   answer = Some(vec![])  →  s = ""  →  s.trim().is_empty() → fallback
761        let raw_answer: Option<Vec<u8>> = Some(vec![]); // empty body from 200 OK
762
763        let resolved_answer = match raw_answer {
764            Some(bytes) => {
765                let s = String::from_utf8_lossy(&bytes).to_string();
766                if s.trim().is_empty() {
767                    // Fallback: use early SDP stored by the 183 handler
768                    let cs = call_state.read().await;
769                    match cs.answer.clone() {
770                        Some(early_sdp) if !early_sdp.is_empty() => {
771                            (early_sdp, true /* already applied */)
772                        }
773                        _ => (s, false),
774                    }
775                } else {
776                    (s, false)
777                }
778            }
779            None => {
780                let cs = call_state.read().await;
781                match cs.answer.clone() {
782                    Some(early_sdp) if !early_sdp.is_empty() => (early_sdp, true),
783                    _ => panic!("Expected early SDP fallback"),
784                }
785            }
786        };
787
788        let (answer, already_applied) = resolved_answer;
789
790        // The answer returned to setup_caller_track (and used in SessionEvent::Answer)
791        // must be the early SDP, not an empty string.
792        assert!(
793            !answer.is_empty(),
794            "Resolved answer must not be empty — should contain the early SDP"
795        );
796        assert_eq!(
797            answer, EARLY_MEDIA_SDP,
798            "Resolved answer should be the early SDP from the 183 handler"
799        );
800        assert!(
801            already_applied,
802            "remote_description_already_applied should be true when using early SDP fallback"
803        );
804    }
805
806    /// Verify the normal case: when 200 OK carries its own SDP body,
807    /// that SDP is used directly (not the early SDP) and remote description
808    /// should be applied.
809    #[tokio::test]
810    async fn test_answer_uses_200ok_sdp_when_present() {
811        const FINAL_SDP: &str = "v=0\r\n\
812            o=- 2000 2 IN IP4 10.0.0.1\r\n\
813            s=SIP Call\r\n\
814            t=0 0\r\n\
815            m=audio 20000 RTP/AVP 0\r\n\
816            c=IN IP4 10.0.0.1\r\n\
817            a=rtpmap:0 PCMU/8000\r\n\
818            a=sendrecv\r\n";
819
820        let call_state: ActiveCallStateRef = Arc::new(RwLock::new(ActiveCallState::default()));
821
822        // Even with early SDP stored, when 200 OK has SDP body it should be used
823        {
824            let mut cs = call_state.write().await;
825            cs.answer = Some(EARLY_MEDIA_SDP.to_string());
826        }
827
828        let raw_answer: Option<Vec<u8>> = Some(FINAL_SDP.as_bytes().to_vec());
829
830        let resolved_answer = match raw_answer {
831            Some(bytes) => {
832                let s = String::from_utf8_lossy(&bytes).to_string();
833                if s.trim().is_empty() {
834                    let cs = call_state.read().await;
835                    match cs.answer.clone() {
836                        Some(early_sdp) if !early_sdp.is_empty() => (early_sdp, true),
837                        _ => (s, false),
838                    }
839                } else {
840                    (s, false) // ← normal case: use 200 OK SDP, apply it
841                }
842            }
843            None => panic!("Unexpected"),
844        };
845
846        let (answer, already_applied) = resolved_answer;
847
848        assert_eq!(
849            answer, FINAL_SDP,
850            "When 200 OK has SDP, it should be used (not the early SDP)"
851        );
852        assert!(
853            !already_applied,
854            "remote_description_already_applied should be false when 200 OK has SDP body"
855        );
856    }
857}