use std::time::Duration;
use rsiprtp::session::{CallManager, Dialog, ManagerConfig, OutboundRequestKind};
use rsiprtp::sip::{Method, SipMessage, SipRequest, SipResponse};
use rsiprtp::transaction::server::invite::{
Action as InviteServerAction, Event as InviteServerEvent, FinalSent, InviteServerTransaction,
State as InviteServerState,
};
use rsiprtp::transaction::Timer;
fn answer_sdp() -> rsiprtp::sdp::parser::SessionDescription {
rsiprtp::sdp::parser::SessionDescription::parse(
"v=0\r\no=- 1 1 IN IP4 10.0.0.2\r\ns=-\r\nc=IN IP4 10.0.0.2\r\nt=0 0\r\n\
m=audio 6000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\na=sendrecv\r\n",
)
.expect("answer sdp")
}
fn established_outbound_call_with_cseq(
manager: &mut CallManager,
local_cseq: u32,
) -> rsiprtp::session::CallId {
let call_id = manager.create_call("sip:bob@carrier.example.com".to_string());
let dialog = Dialog::new_uac(
"prack-call-1@example.com".to_string(),
"alice-tag".to_string(),
"bob-tag".to_string(),
"sip:alice@example.com".to_string(),
"sip:bob@carrier.example.com".to_string(),
local_cseq,
);
manager.handle_invite_success(
&call_id,
dialog,
&answer_sdp(),
None,
std::time::Instant::now(),
);
let _ = manager.drain_events();
call_id
}
#[test]
fn test_prack_sent_on_reliable_provisional() {
const DIALOG_LOCAL_CSEQ: u32 = 5;
const RESPONSE_CSEQ: u32 = 7;
assert_ne!(
DIALOG_LOCAL_CSEQ, RESPONSE_CSEQ,
"test fixture must use distinct values to disambiguate RAck source"
);
let mut manager = CallManager::new(ManagerConfig::default());
let call_id = established_outbound_call_with_cseq(&mut manager, DIALOG_LOCAL_CSEQ);
let raw_180 = format!(
"SIP/2.0 180 Ringing\r\n\
Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bKabc\r\n\
Record-Route: <sip:proxy.example.com;lr>\r\n\
From: <sip:alice@example.com>;tag=alice-tag\r\n\
To: <sip:bob@carrier.example.com>;tag=bob-tag\r\n\
Contact: <sip:bob@10.0.0.2:5060>\r\n\
Call-ID: prack-call-1@example.com\r\n\
CSeq: {} INVITE\r\n\
Require: 100rel\r\n\
RSeq: 1\r\n\
Content-Length: 0\r\n\
\r\n",
RESPONSE_CSEQ
);
let one_eighty = SipMessage::parse(raw_180.as_bytes())
.expect("parse 180")
.as_response()
.expect("response")
.clone();
manager.handle_provisional_response(&call_id, &one_eighty, None, "sip:alice@10.0.0.1:5060");
let prack = manager
.handle_provisional_reliable(&call_id, &one_eighty)
.expect("PRACK built");
assert_eq!(prack.method(), Method::Prack);
let rack = prack.rack().expect("RAck on PRACK");
assert_eq!(rack.rseq, 1, "RAck rseq should match 180's RSeq");
assert_eq!(
rack.cseq, RESPONSE_CSEQ,
"RAck cseq must echo the response's CSeq (RFC 3262 §7.2), not the dialog's local CSeq"
);
assert_ne!(
rack.cseq, DIALOG_LOCAL_CSEQ,
"RAck cseq MUST NOT equal the dialog's local CSeq — that would mean we sourced \
it from the wrong place; the test seed disambiguates the two on purpose"
);
assert_eq!(rack.method, Method::Invite, "RAck method is INVITE");
assert_eq!(
prack.cseq().expect("PRACK has CSeq"),
DIALOG_LOCAL_CSEQ + 1,
"PRACK's own CSeq must be dialog.local_cseq + 1 (RFC 3262 §7.2 — \
PRACK is a new transaction)"
);
let bytes = prack.to_bytes();
let prack_parsed = SipMessage::parse(&bytes)
.expect("parse PRACK")
.as_request()
.expect("request")
.clone();
let routes = prack_parsed.route_headers();
assert_eq!(routes.len(), 1, "PRACK must carry exactly one Route header");
assert!(
routes[0].contains("proxy.example.com"),
"Route must carry the proxy: {:?}",
routes[0]
);
assert!(
prack_parsed.contact_uri().is_some(),
"PRACK must carry a Contact header"
);
let outbound = manager.drain_outbound_requests();
assert_eq!(outbound.len(), 1, "exactly one outbound request");
assert_eq!(outbound[0].kind, OutboundRequestKind::Prack);
}
fn create_uas_invite() -> SipRequest {
SipRequest::builder()
.method(Method::Invite)
.uri("sip:bob@example.com")
.via("10.0.0.99", 5060, "UDP", "z9hG4bKpeer")
.from("sip:alice@example.com", "fromtag")
.to("sip:bob@example.com")
.call_id("prack-uas-1@example.com")
.cseq(1)
.require(&["100rel"])
.supported(&["timer", "100rel"])
.build()
.expect("inbound INVITE")
}
fn build_180_for(invite: &SipRequest) -> SipResponse {
SipResponse::builder()
.status(180, "Ringing")
.from_request(invite)
.to_tag("uastag")
.build()
.expect("180 builds")
}
#[test]
fn test_uas_reliable_provisional_stops_on_prack() {
let invite = create_uas_invite();
let mut tx = InviteServerTransaction::new(invite.clone(), false ).unwrap();
tx.poll_actions();
let one_eighty = build_180_for(&invite);
tx.send_provisional_reliable(one_eighty);
let actions = tx.poll_actions();
let send_bytes = actions
.iter()
.find_map(|a| {
if let InviteServerAction::Send(b) = a {
Some(b.clone())
} else {
None
}
})
.expect("Send action with 180 bytes");
let wire = String::from_utf8(send_bytes.to_vec()).unwrap();
assert!(
wire.contains("Require: 100rel") || wire.contains("Require:100rel"),
"180 must carry Require: 100rel, got:\n{}",
wire
);
assert!(
wire.contains("RSeq: 1") || wire.contains("RSeq:1"),
"180 must carry RSeq: 1, got:\n{}",
wire
);
let timer_n_set = actions
.iter()
.any(|a| matches!(a, InviteServerAction::SetTimer(Timer::N, _)));
assert!(
timer_n_set,
"Timer N must be set after reliable provisional"
);
tx.handle_timeout(Timer::N);
let after_n = tx.poll_actions();
let resent = after_n
.iter()
.any(|a| matches!(a, InviteServerAction::Send(_)));
let rescheduled = after_n
.iter()
.any(|a| matches!(a, InviteServerAction::SetTimer(Timer::N, _)));
assert!(resent, "180 must retransmit on Timer N");
assert!(rescheduled, "Timer N must reschedule itself");
tx.handle_event(InviteServerEvent::PrackReceived(1));
let after_prack = tx.poll_actions();
let cancelled = after_prack
.iter()
.any(|a| matches!(a, InviteServerAction::CancelTimer(Timer::N)));
assert!(
cancelled,
"Timer N must be cancelled when matching PRACK arrives"
);
tx.handle_timeout(Timer::N);
let after_2 = tx.poll_actions();
let any_send = after_2
.iter()
.any(|a| matches!(a, InviteServerAction::Send(_)));
assert!(
!any_send,
"no further retransmits after PRACK drains the buffer, got {:?}",
after_2
);
assert_eq!(tx.state(), InviteServerState::Proceeding);
}
#[test]
fn test_prack_timeout_emits_504() {
let invite = create_uas_invite();
let mut tx = InviteServerTransaction::new(invite.clone(), false).unwrap();
tx.poll_actions();
let one_eighty = build_180_for(&invite);
tx.send_provisional_reliable(one_eighty);
tx.poll_actions();
let mut saw_timeout = false;
let mut final_sent_observed: Option<FinalSent> = None;
let mut scheduled_total = Duration::ZERO;
let mut retransmit_count: usize = 0;
let mut iterations: usize = 0;
for _ in 0..32 {
iterations += 1;
tx.handle_timeout(Timer::N);
let actions = tx.poll_actions();
for a in actions {
match a {
InviteServerAction::Send(_) => {
retransmit_count += 1;
}
InviteServerAction::SetTimer(Timer::N, d) => {
scheduled_total += d;
}
InviteServerAction::Event(InviteServerEvent::PrackTimeout { rseq, final_sent }) => {
assert_eq!(rseq, 1, "PrackTimeout rseq must be 1");
final_sent_observed = Some(final_sent);
saw_timeout = true;
}
_ => {}
}
}
if saw_timeout {
break;
}
}
assert!(
saw_timeout,
"PrackTimeout must fire when no PRACK arrives within 64*T1"
);
assert!(
scheduled_total >= Duration::from_secs(32),
"Timer N must accumulate ≥ 64*T1 (32s) of scheduled fires \
before abandoning; got {:?} across {} iterations",
scheduled_total,
iterations
);
assert!(
retransmit_count >= 2,
"expected multiple retransmits before PrackTimeout; got {} \
(regression: timer fired PrackTimeout on the first tick?)",
retransmit_count
);
assert_eq!(
final_sent_observed,
Some(FinalSent::None),
"final_sent should be None — TU's signal to send 504 Server Time-out"
);
let resp_504 = SipResponse::builder()
.status(504, "Server Time-out")
.from_request(&invite)
.to_tag("uastag")
.build()
.expect("504 builds");
tx.send_response(resp_504);
let actions = tx.poll_actions();
let sent = actions
.iter()
.any(|a| matches!(a, InviteServerAction::Send(_)));
assert!(sent, "TU's 504 must be queued for the wire");
assert_eq!(tx.state(), InviteServerState::Completed);
}