use active_call::app::{AppState, AppStateBuilder};
use active_call::config::{Config, InviteHandlerConfig};
use anyhow::Result;
use axum::{Router, extract::Json, http::StatusCode as HttpStatusCode, routing::post};
use rsipstack::dialog::invitation::InviteOption;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{net::TcpListener, sync::mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
#[derive(Debug, Clone)]
struct WebhookRequest {
body: serde_json::Value,
headers: HashMap<String, String>,
}
async fn create_test_useragent(webhook_url: String) -> Result<AppState> {
let mut config = Config::default();
config.http_addr = "127.0.0.1:0".to_string();
config.addr = "127.0.0.1".to_string();
config.udp_port = 0; config.accept_timeout = Some("30s".to_string()); config.handler = Some(InviteHandlerConfig::Webhook {
url: Some(webhook_url),
urls: None,
method: Some("POST".to_string()),
headers: Some({
vec![
("Content-Type".to_string(), "application/json".to_string()),
("X-Test-Header".to_string(), "test-value".to_string()),
]
}),
});
let ua = AppStateBuilder::new()
.with_config(config)
.with_cancel_token(CancellationToken::new())
.build()
.await?;
Ok(ua)
}
async fn create_simple_useragent(listen_addr: String) -> Result<AppState> {
let mut config = Config::default();
config.http_addr = "127.0.0.1:0".to_string();
config.addr = listen_addr;
config.udp_port = 0;
let ua = AppStateBuilder::new()
.with_config(config)
.with_cancel_token(CancellationToken::new())
.build()
.await?;
Ok(ua)
}
#[tokio::test]
async fn test_bob_call_alice_webhook_accept() -> Result<()> {
let webhook_requests = Arc::new(Mutex::new(Vec::<WebhookRequest>::new()));
let webhook_requests_clone = webhook_requests.clone();
let alice_dialog_id = Arc::new(Mutex::new(None::<String>));
let alice_dialog_id_clone = alice_dialog_id.clone();
let webhook_app = Router::new().route(
"/webhook",
post(
move |headers: axum::http::HeaderMap, Json(body): Json<serde_json::Value>| async move {
let mut webhook_headers = HashMap::new();
for (k, v) in headers.iter() {
webhook_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
}
let webhook_req = WebhookRequest {
body: body.clone(),
headers: webhook_headers,
};
webhook_requests_clone.lock().unwrap().push(webhook_req);
if let Some(dialog_id) = body.get("dialogId") {
if let Some(id_str) = dialog_id.as_str() {
*alice_dialog_id_clone.lock().unwrap() = Some(id_str.to_string());
}
}
info!("Webhook received: {:?}", body);
(HttpStatusCode::OK, "OK")
},
),
);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let webhook_addr = listener.local_addr()?;
let webhook_url = format!("http://127.0.0.1:{}/webhook", webhook_addr.port());
tokio::spawn(async move {
axum::serve(listener, webhook_app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
let alice_ua = create_test_useragent(webhook_url).await?;
let alice_local_addr = alice_ua.endpoint.get_addrs().first().cloned().unwrap();
info!("Alice AppState listening on: {}", alice_local_addr);
let bob_ua = create_simple_useragent("127.0.0.1".to_string()).await?;
let bob_local_addr = bob_ua.endpoint.get_addrs().first().cloned().unwrap();
info!("Bob AppState listening on: {}", bob_local_addr);
let alice_token = alice_ua.token.clone();
let bob_token = bob_ua.token.clone();
let alice_ua_arc = alice_ua.clone();
let bob_ua_arc = bob_ua.clone();
let test_result = tokio::select! {
alice_result = alice_ua_arc.clone().serve() => {
warn!("Alice serve finished: {:?}", alice_result);
Err(anyhow::anyhow!("Alice serve finished unexpectedly"))
}
bob_result = bob_ua_arc.clone().serve() => {
warn!("Bob serve finished: {:?}", bob_result);
Err(anyhow::anyhow!("Bob serve finished unexpectedly"))
}
test_result = async {
tokio::time::sleep(Duration::from_millis(1000)).await;
let (state_sender, _state_receiver) = mpsc::unbounded_channel();
let alice_uri = format!("sip:alice@{}", alice_local_addr.addr);
let bob_uri = format!("sip:bob@{}", bob_local_addr.addr);
info!("Alice URI: {}, Bob URI: {}", alice_uri, bob_uri);
let invite_option = InviteOption {
caller: bob_uri.clone().try_into()?,
callee: alice_uri.try_into()?,
content_type: Some("application/sdp".to_string()),
offer: Some(b"v=0\r\no=bob 123456 123456 IN IP4 127.0.0.1\r\ns=Call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n".to_vec()),
contact: bob_uri.try_into()?,
..Default::default()
};
info!("Bob initiating call to Alice...");
let bob_ua_invite = bob_ua_arc.invitation.clone();
let invite_handle = tokio::spawn(async move {
bob_ua_invite.invite(invite_option, state_sender).await
});
let mut webhook_received = false;
let mut alice_dialog_id_received = None;
for i in 0..150 { if !webhook_requests.lock().unwrap().is_empty() {
webhook_received = true;
alice_dialog_id_received = alice_dialog_id.lock().unwrap().clone();
info!("Webhook received after {}ms", i * 100);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if !webhook_received {
warn!("Webhook was not called within timeout");
invite_handle.abort();
return Ok(());
}
let webhook_reqs = webhook_requests.lock().unwrap();
let webhook_req = &webhook_reqs[0];
assert_eq!(webhook_req.body["event"], "invite");
assert!(webhook_req.body["caller"].as_str().unwrap().contains("bob"));
assert!(webhook_req.body["callee"].as_str().unwrap().contains("alice"));
assert!(webhook_req.body["headers"].as_array().unwrap().iter().any(|h| h.as_str().unwrap().contains("application/sdp")));
assert!(webhook_req.headers.contains_key("content-type"));
assert!(webhook_req.headers.contains_key("x-test-header"));
info!("Webhook validation passed");
if let Some(dialog_id_str) = alice_dialog_id_received {
if let Some(dialog_id) = alice_ua_arc.invitation.find_dialog_id_by_session_id(&dialog_id_str) {
if let Some(pending_call) = alice_ua_arc.invitation.get_pending_call(&dialog_id) {
info!("Alice accepting call with dialog_id: {}", dialog_id_str);
let answer_sdp = b"v=0\r\no=alice 654321 654321 IN IP4 127.0.0.1\r\ns=Call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 49171 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n";
let headers = Some(vec![rsipstack::rsip::Header::ContentType("application/sdp".to_string().into())]);
match pending_call.dialog.accept(headers, Some(answer_sdp.to_vec())) {
Ok(_) => {
info!("Alice accepted the call successfully");
}
Err(e) => {
warn!("Alice failed to accept call: {:?}", e);
}
}
} else {
warn!("No pending call found for Alice with dialog_id: {}", dialog_id_str);
}
} else {
warn!("No dialog ID found in pending dialogs for: {}", dialog_id_str);
}
} else {
warn!("No dialog ID found for Alice");
}
match invite_handle.await {
Ok(Ok((dialog_id, response))) => {
info!("Bob's call completed successfully, dialog_id: {:?}", dialog_id);
if let Some(resp_body) = response {
info!("Response body: {:?}", String::from_utf8_lossy(&resp_body));
}
}
Ok(Err(e)) => {
warn!("Bob's call failed: {:?}", e);
}
Err(e) => {
warn!("Bob's invite task failed: {:?}", e);
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
info!("Test completed successfully");
Ok(())
} => {
test_result
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
panic!("test timeout");
}
};
alice_token.cancel();
bob_token.cancel();
tokio::time::sleep(Duration::from_millis(100)).await;
test_result.expect("test failed");
Ok(())
}
#[tokio::test]
async fn test_incoming_call_reject() -> Result<()> {
let webhook_requests = Arc::new(Mutex::new(Vec::<WebhookRequest>::new()));
let webhook_requests_clone = webhook_requests.clone();
let alice_dialog_id = Arc::new(Mutex::new(None::<String>));
let alice_dialog_id_clone = alice_dialog_id.clone();
let webhook_app = Router::new().route(
"/webhook",
post(
move |headers: axum::http::HeaderMap, Json(body): Json<serde_json::Value>| async move {
let mut webhook_headers = HashMap::new();
for (k, v) in headers.iter() {
webhook_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
}
let webhook_req = WebhookRequest {
body: body.clone(),
headers: webhook_headers,
};
webhook_requests_clone.lock().unwrap().push(webhook_req);
if let Some(dialog_id) = body.get("dialogId") {
if let Some(id_str) = dialog_id.as_str() {
*alice_dialog_id_clone.lock().unwrap() = Some(id_str.to_string());
}
}
info!("Webhook received: {:?}", body);
(HttpStatusCode::OK, "OK")
},
),
);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let webhook_addr = listener.local_addr()?;
let webhook_url = format!("http://127.0.0.1:{}/webhook", webhook_addr.port());
info!("Webhook server listening on {}", webhook_url);
tokio::spawn(async move {
axum::serve(listener, webhook_app).await.unwrap();
});
let alice_ua_arc = create_test_useragent(webhook_url).await?;
let alice_token = alice_ua_arc.token.clone();
let bob_addr = "127.0.0.1".to_string();
let bob_ua_arc = create_simple_useragent(bob_addr).await?;
let bob_token = bob_ua_arc.token.clone();
let alice_ua_run = alice_ua_arc.clone();
let bob_ua_run = bob_ua_arc.clone();
let test_logic = async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let alice_local_addr = alice_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let bob_local_addr = bob_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let alice_uri = format!("sip:alice@{}", alice_local_addr.addr);
let bob_uri = format!("sip:bob@{}", bob_local_addr.addr);
info!("Alice URI: {}, Bob URI: {}", alice_uri, bob_uri);
let sdp = b"v=0\r\no=bob 123456 123456 IN IP4 127.0.0.1\r\ns=Call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n";
let invite_option = InviteOption {
caller: bob_uri.clone().try_into()?,
callee: alice_uri.try_into()?,
content_type: Some("application/sdp".to_string()),
offer: Some(sdp.to_vec()),
contact: bob_uri.try_into()?,
..Default::default()
};
let (state_sender, _state_receiver) = mpsc::unbounded_channel();
let bob_ua_invite = bob_ua_arc.invitation.clone();
let invite_handle =
tokio::spawn(async move { bob_ua_invite.invite(invite_option, state_sender).await });
let mut webhook_received = false;
let mut alice_dialog_id_received = None;
for i in 0..50 {
if !webhook_requests.lock().unwrap().is_empty() {
webhook_received = true;
alice_dialog_id_received = alice_dialog_id.lock().unwrap().clone();
info!("Webhook received after {}ms", i * 100);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if !webhook_received {
invite_handle.abort();
return Err(anyhow::anyhow!("Webhook was not called within timeout"));
}
if let Some(dialog_id_str) = alice_dialog_id_received {
if let Some(dialog_id) = alice_ua_arc
.invitation
.find_dialog_id_by_session_id(&dialog_id_str)
{
if let Some(pending_call) = alice_ua_arc.invitation.get_pending_call(&dialog_id) {
info!("Alice REJECTING call with dialog_id: {}", dialog_id_str);
pending_call
.dialog
.reject(Some(rsipstack::rsip::StatusCode::Decline), None)
.ok();
} else {
return Err(anyhow::anyhow!("No pending call found for Alice"));
}
} else {
return Err(anyhow::anyhow!("No dialog ID found in pending dialogs"));
}
}
match invite_handle.await {
Ok(Err(e)) => {
info!("Bob's call failed as expected: {:?}", e);
}
Ok(Ok(_)) => {
return Err(anyhow::anyhow!(
"Bob call succeeded but should have been rejected"
));
}
Err(e) => {
return Err(e.into());
}
}
Ok(())
};
let test_result = tokio::select! {
_ = alice_ua_run.serve() => Err(anyhow::anyhow!("Alice stopped unexpectedly")),
_ = bob_ua_run.serve() => Err(anyhow::anyhow!("Bob stopped unexpectedly")),
res = test_logic => res,
};
alice_token.cancel();
bob_token.cancel();
test_result
}
#[tokio::test]
async fn test_outgoing_call_remote_reject() -> Result<()> {
let alice_ua_arc = create_simple_useragent("127.0.0.1".to_string()).await?;
let alice_token = alice_ua_arc.token.clone();
let bob_ua_arc = create_simple_useragent("127.0.0.1".to_string()).await?;
let bob_token = bob_ua_arc.token.clone();
let alice_ua_run = alice_ua_arc.clone();
let bob_ua_run = bob_ua_arc.clone();
let test_logic = async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let alice_local_addr = alice_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let bob_local_addr = bob_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let alice_uri = format!("sip:alice@{}", alice_local_addr.addr);
let bob_uri = format!("sip:bob@{}", bob_local_addr.addr);
let sdp = b"v=0\r\no=alice 111 111 IN IP4 127.0.0.1\r\ns=Call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 50000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n";
let invite_option = InviteOption {
caller: alice_uri.clone().try_into()?,
callee: bob_uri.try_into()?,
content_type: Some("application/sdp".to_string()),
offer: Some(sdp.to_vec()),
contact: alice_uri.try_into()?,
..Default::default()
};
let (tx, _rx) = mpsc::unbounded_channel();
let result = alice_ua_arc.invitation.invite(invite_option, tx).await;
match result {
Ok(_) => Err(anyhow::anyhow!(
"Outgoing call should have been rejected by Bob who has no handler"
)),
Err(e) => {
info!("Outgoing call rejected as expected: {:?}", e);
Ok(())
}
}
};
let test_result = tokio::select! {
_ = alice_ua_run.serve() => Err(anyhow::anyhow!("Alice stopped unexpectedly")),
_ = bob_ua_run.serve() => Err(anyhow::anyhow!("Bob stopped unexpectedly")),
res = test_logic => res,
};
alice_token.cancel();
bob_token.cancel();
test_result
}
#[tokio::test]
async fn test_webhook_unavailable_immediate_reject() -> Result<()> {
let unavailable_webhook_url = "http://127.0.0.1:9999/webhook".to_string(); let alice_ua_arc = create_test_useragent(unavailable_webhook_url).await?;
let alice_token = alice_ua_arc.token.clone();
let bob_ua_arc = create_simple_useragent("127.0.0.1".to_string()).await?;
let bob_token = bob_ua_arc.token.clone();
let alice_ua_run = alice_ua_arc.clone();
let bob_ua_run = bob_ua_arc.clone();
let test_logic = async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let alice_local_addr = alice_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let bob_local_addr = bob_ua_arc.endpoint.get_addrs().first().cloned().unwrap();
let alice_uri = format!("sip:alice@{}", alice_local_addr.addr);
let bob_uri = format!("sip:bob@{}", bob_local_addr.addr);
info!("Bob calling Alice at {}", alice_uri);
let sdp = b"v=0\r\no=bob 123 123 IN IP4 127.0.0.1\r\ns=Call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 50000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n";
let invite_option = InviteOption {
caller: bob_uri.clone().try_into()?,
callee: alice_uri.try_into()?,
content_type: Some("application/sdp".to_string()),
offer: Some(sdp.to_vec()),
contact: bob_uri.try_into()?,
..Default::default()
};
let (tx, _rx) = mpsc::unbounded_channel();
let start_time = std::time::Instant::now();
let call_result = tokio::time::timeout(
Duration::from_secs(5), bob_ua_arc.invitation.invite(invite_option, tx),
)
.await;
let elapsed = start_time.elapsed();
info!("Call completed in {:?}", elapsed);
match call_result {
Ok(Ok(_)) => {
return Err(anyhow::anyhow!(
"Call should have been rejected due to webhook unavailability"
));
}
Ok(Err(e)) => {
info!("Call rejected as expected: {:?}", e);
if elapsed > Duration::from_secs(3) {
warn!(
"Call took {:?} to reject, expected faster response",
elapsed
);
} else {
info!("Call rejected quickly in {:?}", elapsed);
}
let error_msg = format!("{:?}", e);
if error_msg.contains("503") || error_msg.contains("Service") {
info!("Received expected 503 Service Unavailable response");
} else {
info!("Error message: {}", error_msg);
}
Ok(())
}
Err(_) => {
return Err(anyhow::anyhow!(
"Call timed out after {:?}, should have been rejected immediately",
elapsed
));
}
}
};
let test_result = tokio::select! {
_ = alice_ua_run.serve() => Err(anyhow::anyhow!("Alice stopped unexpectedly")),
_ = bob_ua_run.serve() => Err(anyhow::anyhow!("Bob stopped unexpectedly")),
res = test_logic => res,
_ = tokio::time::sleep(Duration::from_secs(10)) => {
Err(anyhow::anyhow!("Test timed out"))
}
};
alice_token.cancel();
bob_token.cancel();
test_result
}