use crate::call::{DialDirection, DialStrategy, RoutingState};
use crate::call::{FailureAction, QueueFallbackAction};
use crate::config::{MediaProxyMode, RecordingPolicy, RouteResult};
use crate::proxy::call::q850_reason_value;
use crate::proxy::routing::matcher::{RouteResourceLookup, match_invite};
use crate::proxy::routing::{
DestConfig, MatchConditions, MediaMode, QueueDialMode, RejectConfig, RewriteRules, RouteAction,
RouteQueueConfig, RouteQueueFallbackConfig, RouteQueueHoldConfig, RouteQueueStrategyConfig,
RouteQueueTargetConfig, RouteRule, SourceTrunk, TrunkConfig, TrunkDirection, VideoPolicy,
};
use async_trait::async_trait;
use rsipstack::dialog::invitation::InviteOption;
use rsipstack::sip::StatusCode;
use std::sync::Arc;
use std::{collections::HashMap, net::IpAddr};
#[tokio::test]
async fn test_match_invite_no_routes() {
let routing_state = Arc::new(RoutingState::new());
let option = create_test_invite_option();
let origin = create_test_request();
let trunks = HashMap::new();
let routes = vec![];
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(_, _) | RouteResult::NotHandled(_, _) => {} RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::Queue { .. } => {
panic!("Unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_source_trunk_codecs_apply_when_no_route_handles_call() {
let routing_state = Arc::new(RoutingState::new());
let option = create_test_invite_option();
let origin = create_test_request();
let mut trunks = HashMap::new();
trunks.insert(
"inbound-trunk".to_string(),
TrunkConfig {
dest: "sip:192.168.3.7:5060".to_string(),
codec: vec!["pcma".to_string()],
direction: Some(TrunkDirection::Inbound),
inbound_hosts: vec!["192.168.3.7".to_string()],
..Default::default()
},
);
let routes = vec![];
let source_trunk = SourceTrunk {
name: "inbound-trunk".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
Some(&source_trunk),
routing_state,
&DialDirection::Inbound,
)
.await
.expect("invite should resolve to not handled with source trunk hints");
match result {
RouteResult::NotHandled(_, Some(hints)) => {
assert_eq!(hints.allow_codecs, Some(vec!["pcma".to_string()]));
}
_ => panic!("expected not handled with source trunk codec hints"),
}
}
#[tokio::test]
async fn test_selected_trunk_codecs_override_source_trunk_codecs() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"inbound-trunk".to_string(),
TrunkConfig {
dest: "sip:192.168.3.7:5060".to_string(),
codec: vec!["pcma".to_string()],
direction: Some(TrunkDirection::Inbound),
inbound_hosts: vec!["192.168.3.7".to_string()],
..Default::default()
},
);
trunks.insert(
"outbound-trunk".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
codec: vec!["pcmu".to_string()],
direction: Some(TrunkDirection::Outbound),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "forward_to_outbound".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("outbound-trunk".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let source_trunk = SourceTrunk {
name: "inbound-trunk".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
Some(&source_trunk),
routing_state,
&DialDirection::Inbound,
)
.await
.expect("invite should match outbound route");
match result {
RouteResult::Forward(_, Some(hints)) => {
assert_eq!(hints.allow_codecs, Some(vec!["pcmu".to_string()]));
}
_ => panic!("expected forward with selected trunk codec hints"),
}
}
#[tokio::test]
async fn test_trunk_matches_inbound_ip_with_cidr() {
let trunk = TrunkConfig {
dest: "sip:10.0.0.1:5060".to_string(),
inbound_hosts: vec!["192.168.10.0/24".to_string()],
..Default::default()
};
let inside: IpAddr = "192.168.10.42".parse().unwrap();
let outside: IpAddr = "192.168.20.1".parse().unwrap();
assert!(trunk.matches_inbound_ip(&inside).await);
assert!(!trunk.matches_inbound_ip(&outside).await);
}
#[tokio::test]
async fn test_trunk_matches_inbound_ip_with_hostname() {
let trunk = TrunkConfig {
dest: "sip:localhost:5060".to_string(),
inbound_hosts: vec!["localhost".to_string()],
..Default::default()
};
let loopback: IpAddr = "127.0.0.1".parse().unwrap();
assert!(trunk.matches_inbound_ip(&loopback).await);
}
#[test]
fn test_trunk_incoming_user_prefix_plain() {
let trunk = TrunkConfig {
incoming_from_user_prefix: Some("+852".to_string()),
..Default::default()
};
assert!(
trunk
.matches_incoming_user_prefixes(Some("+852123456"), None)
.unwrap()
);
assert!(
trunk
.matches_incoming_user_prefixes(Some("+853123456"), None)
.is_err()
);
}
#[test]
fn test_trunk_incoming_user_prefix_regex() {
let trunk = TrunkConfig {
incoming_to_user_prefix: Some(r"^\d{10}$".to_string()),
..Default::default()
};
assert!(
trunk
.matches_incoming_user_prefixes(None, Some("1234567890"))
.unwrap()
);
assert!(
trunk
.matches_incoming_user_prefixes(None, Some("1234"))
.is_err()
);
}
#[test]
fn test_trunk_incoming_user_prefix_invalid_regex() {
let trunk = TrunkConfig {
incoming_from_user_prefix: Some("^(".to_string()),
..Default::default()
};
let err = trunk
.matches_incoming_user_prefixes(Some("1001"), None)
.unwrap_err();
assert!(err.to_string().contains("invalid regex"));
}
#[test]
fn test_trunk_incoming_user_prefix_mismatch_q850_reason_format() {
let trunk = TrunkConfig {
incoming_from_user_prefix: Some("+852".to_string()),
incoming_to_user_prefix: Some("601285".to_string()),
..Default::default()
};
let result = trunk.matches_incoming_user_prefixes(Some("+853123456"), None);
assert!(result.is_err());
let err = result.unwrap_err();
let reason = q850_reason_value(&StatusCode::Forbidden, Some(&err.to_string()));
assert!(
reason.starts_with("Q.850;cause=21;text="),
"reason should be Q.850 format with cause 21, got: {}",
reason
);
assert!(
reason.contains("from_user"),
"reason should mention field name, got: {}",
reason
);
assert!(
reason.contains("expected '+852'"),
"reason should show expected value, got: {}",
reason
);
assert!(
reason.contains("got '+853123456'"),
"reason should show actual value, got: {}",
reason
);
let result = trunk.matches_incoming_user_prefixes(Some("+852123456"), Some("601286"));
assert!(result.is_err());
let err = result.unwrap_err();
let reason = q850_reason_value(&StatusCode::Forbidden, Some(&err.to_string()));
assert!(
reason.starts_with("Q.850;cause=21;text="),
"reason should be Q.850 format with cause 21, got: {}",
reason
);
assert!(
reason.contains("to_user"),
"reason should mention field name, got: {}",
reason
);
assert!(
reason.contains("expected '601285'"),
"reason should show expected value, got: {}",
reason
);
assert!(
reason.contains("got '601286'"),
"reason should show actual value, got: {}",
reason
);
}
#[tokio::test]
async fn test_match_invite_inbound_respects_source_trunk() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"ingress".to_string(),
TrunkConfig {
dest: "sip:ingress.gateway.local:5060".to_string(),
direction: Some(TrunkDirection::Inbound),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "inbound-route".to_string(),
priority: 10,
source_trunks: vec!["ingress".to_string()],
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("ingress".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_test_request();
let source_trunk = SourceTrunk {
name: "ingress".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
Some(&source_trunk),
routing_state,
&DialDirection::Inbound,
)
.await
.expect("invite should resolve");
match result {
RouteResult::Forward(_, _) => {}
RouteResult::NotHandled(_, _) | RouteResult::Abort(_, _) => {
panic!("expected inbound invite to forward")
}
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_source_trunks_must_match_inbound() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"ingress".to_string(),
TrunkConfig {
dest: "sip:ingress.gateway.local:5060".to_string(),
direction: Some(TrunkDirection::Inbound),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "inbound-route".to_string(),
priority: 10,
source_trunks: vec!["ingress".to_string()],
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("ingress".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Inbound,
)
.await
.expect("invite should resolve");
match result {
RouteResult::NotHandled(_, _) => {}
RouteResult::Forward(_, _) | RouteResult::Abort(_, _) => {
panic!("expected invite to be left unhandled when source trunk is missing")
}
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_exact_match() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"test_trunk".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
username: Some("testuser".to_string()),
password: Some("testpass".to_string()),
transport: Some("udp".to_string()),
codec: vec!["pcma".to_string()],
media_mode: Some(MediaMode::Bypass),
video_policy: Some(VideoPolicy::Strip),
recording: Some(RecordingPolicy {
enabled: true,
auto_start: Some(false),
..Default::default()
}),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "test_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
rewrite: None,
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("test_trunk".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.expect("Failed to match invite");
match result {
RouteResult::NotHandled(_, _) => panic!("Expected forward, got not handled"),
RouteResult::Forward(option, hints) => {
assert!(option.destination.is_some());
let dest = option.destination.unwrap();
assert_eq!(dest.addr.to_string(), "gateway.rustpbx.com:5060");
assert!(option.credential.is_some());
let cred = option.credential.unwrap();
assert_eq!(cred.username, "testuser");
assert_eq!(cred.password, "testpass");
let hints = hints.expect("selected trunk codec should produce dialplan hints");
assert_eq!(hints.allow_codecs, Some(vec!["pcma".to_string()]));
assert_eq!(hints.media_mode, Some(MediaProxyMode::Bypass));
assert_eq!(hints.video_policy, Some(VideoPolicy::Strip));
let recording = hints
.recording
.expect("selected trunk recording should produce recording hint");
assert!(recording.enabled);
assert_eq!(recording.auto_start, Some(false));
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_route_codecs_override_selected_trunk_codecs() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"pcma_trunk".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
codec: vec!["pcma".to_string()],
..Default::default()
},
);
let routes = vec![RouteRule {
name: "route_codec_rule".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("pcma_trunk".to_string())),
select: "rr".to_string(),
..Default::default()
},
codecs: vec!["pcmu".to_string()],
..Default::default()
}];
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
None,
routing_state,
&DialDirection::Outbound,
)
.await
.expect("invite should match route");
match result {
RouteResult::Forward(_, Some(hints)) => {
assert_eq!(hints.allow_codecs, Some(vec!["pcmu".to_string()]));
}
_ => panic!("expected forward with route codec hints"),
}
}
#[test]
fn test_trunk_config_accepts_allow_codecs_alias() {
let trunk: TrunkConfig = toml::from_str(
r#"
dest = "sip:gateway.rustpbx.com:5060"
allow_codecs = ["pcma", "pcmu"]
"#,
)
.expect("trunk config should parse allow_codecs");
assert_eq!(trunk.codec, vec!["pcma".to_string(), "pcmu".to_string()]);
}
#[tokio::test]
async fn test_match_invite_regex_match() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"mobile_trunk".to_string(),
TrunkConfig {
dest: "sip:mobile.gateway.com:5060".to_string(),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "mobile_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
to_user: Some("^1[3-9]\\d{9}$".to_string()), ..Default::default()
},
rewrite: None,
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("mobile_trunk".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:13812345678@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::NotHandled(_option, _) => {
panic!("Expected forward, got NotHandled")
}
RouteResult::Forward(_option, _) => {
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_queue_action_builds_hold_and_fallback() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"support".to_string(),
TrunkConfig {
dest: "sip:support-gateway.rustpbx.com:5060".to_string(),
..Default::default()
},
);
let queue_cfg = RouteQueueConfig {
accept_immediately: true,
passthrough_ringback: true,
hold: Some(RouteQueueHoldConfig {
audio_file: Some("moh/support.wav".to_string()),
loop_playback: true,
}),
fallback: Some(RouteQueueFallbackConfig {
redirect: Some("sip:voicemail@rustpbx.com".to_string()),
failure_code: None,
failure_reason: None,
failure_prompt: None,
queue_ref: None,
skill_group_ref: None,
}),
..RouteQueueConfig::default()
};
let queue_path = "queue/support.toml";
let mut lookup = TestResourceLookup::default();
lookup.add_queue(queue_path, queue_cfg.clone());
let routes = vec![RouteRule {
name: "support-queue".to_string(),
priority: 50,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
action: Some("queue".to_string()),
dest: Some(DestConfig::Single("support".to_string())),
queue: Some(queue_path.to_string()),
..Default::default()
},
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
Some(&lookup),
option,
&origin,
None,
routing_state,
&DialDirection::Inbound,
)
.await
.expect("queue route should resolve");
match result {
RouteResult::Queue { option, queue, .. } => {
assert!(
option.destination.is_some(),
"queue should select trunk destination"
);
assert!(
queue.accept_immediately,
"queue must honor accept_immediately flag"
);
assert!(
queue.passthrough_ringback,
"queue must honor passthrough_ringback flag"
);
let hold = queue.hold.expect("queue hold config missing");
assert_eq!(hold.audio_file.as_deref(), Some("moh/support.wav"));
assert!(hold.loop_playback);
match queue.fallback.expect("fallback missing") {
QueueFallbackAction::Redirect { target } => {
assert_eq!(target.to_string(), "sip:voicemail@rustpbx.com");
}
other => panic!("unexpected fallback action: {:?}", other),
}
}
RouteResult::Forward(_, _) => panic!("route forwarded instead of enqueuing"),
RouteResult::NotHandled(_, _) => panic!("route was not handled"),
RouteResult::Abort(..) => panic!("queue route aborted"),
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_rule_without_source_trunks_matches_any_source() {
let routing_state = Arc::new(RoutingState::new());
let trunks = HashMap::new();
let routes = vec![RouteRule {
name: "catch-all".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("carrier".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let source_trunk = SourceTrunk {
name: "inbound-carrier".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
Some(&source_trunk),
routing_state.clone(),
&DialDirection::Inbound,
)
.await
.expect("rule should match inbound call");
assert!(
matches!(&result, RouteResult::Forward(_, _)),
"expected Forward for inbound call with no source_trunks restriction"
);
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
None,
routing_state.clone(),
&DialDirection::Outbound,
)
.await
.expect("rule should match outbound call");
assert!(
matches!(&result, RouteResult::Forward(_, _)),
"expected Forward for outbound call with no source_trunks restriction"
);
}
#[tokio::test]
async fn test_source_trunks_filters_by_trunk_name() {
let routing_state = Arc::new(RoutingState::new());
let trunks = HashMap::new();
let routes = vec![RouteRule {
name: "specific-trunk-rule".to_string(),
priority: 100,
source_trunks: vec!["carrier-a".to_string()],
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("carrier-a".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let source_trunk = SourceTrunk {
name: "carrier-a".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
Some(&source_trunk),
routing_state.clone(),
&DialDirection::Inbound,
)
.await
.expect("rule should match call from carrier-a");
assert!(
matches!(&result, RouteResult::Forward(_, _)),
"expected Forward for matching source_trunk"
);
let source_trunk_b = SourceTrunk {
name: "carrier-b".to_string(),
id: None,
direction: Some(TrunkDirection::Inbound),
};
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
Some(&source_trunk_b),
routing_state.clone(),
&DialDirection::Inbound,
)
.await
.expect("rule should resolve NotHandled for different source_trunk");
assert!(
matches!(&result, RouteResult::NotHandled(_, _)),
"expected NotHandled for non-matching source_trunk"
);
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
create_test_invite_option(),
&create_test_request(),
None,
routing_state.clone(),
&DialDirection::Outbound,
)
.await
.expect("rule should not match outbound call");
assert!(
matches!(&result, RouteResult::NotHandled(_, _)),
"expected NotHandled for outbound call (no source trunk)"
);
}
#[test]
fn test_queue_fallback_play_then_hangup_action() {
let queue_cfg = RouteQueueConfig {
accept_immediately: true,
hold: None,
fallback: Some(RouteQueueFallbackConfig {
redirect: None,
failure_code: Some(486),
failure_reason: Some("Busy".to_string()),
failure_prompt: Some("prompts/busy.wav".to_string()),
queue_ref: None,
skill_group_ref: None,
}),
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
match plan.fallback.expect("fallback missing") {
QueueFallbackAction::Failure(FailureAction::PlayThenHangup {
audio_file,
status_code,
reason,
..
}) => {
assert_eq!(audio_file, "prompts/busy.wav");
assert_eq!(status_code, StatusCode::BusyHere);
assert_eq!(reason.as_deref(), Some("Busy"));
}
other => panic!("unexpected fallback action: {:?}", other),
}
}
#[test]
fn test_queue_fallback_to_named_queue() {
let queue_cfg = RouteQueueConfig {
fallback: Some(RouteQueueFallbackConfig {
queue_ref: Some("tier2".to_string()),
..RouteQueueFallbackConfig::default()
}),
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
match plan.fallback.expect("fallback missing") {
QueueFallbackAction::Queue { name } => assert_eq!(name, "tier2"),
other => panic!("unexpected fallback action: {:?}", other),
}
}
#[test]
fn test_queue_busy_prompt_maps_to_failure_audio() {
let queue_cfg = RouteQueueConfig {
voice_prompts: Some(crate::call::VoicePrompts {
transfer_prompt: Some("config/sounds/queue-transfer-zh.wav".to_string()),
busy_prompt: Some("config/sounds/queue-busy-zh.wav".to_string()),
off_hours_prompt: None,
no_answer_prompt: None,
}),
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
assert_eq!(
plan.failure_audio.as_deref(),
Some("config/sounds/queue-busy-zh.wav")
);
}
#[test]
fn test_queue_plan_preserves_missing_hold_section() {
let queue_cfg = RouteQueueConfig {
hold: None,
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
assert!(plan.hold.is_none());
}
#[test]
fn test_queue_strategy_builds_dial_targets() {
let queue_cfg = RouteQueueConfig {
strategy: RouteQueueStrategyConfig {
mode: QueueDialMode::Parallel,
wait_timeout_secs: Some(12),
targets: vec![
RouteQueueTargetConfig {
uri: "sip:agent1@pbx.example".to_string(),
label: Some("Agent 1".to_string()),
},
RouteQueueTargetConfig {
uri: "sip:agent2@pbx.example".to_string(),
label: None,
},
],
},
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
match plan.dial_strategy.expect("dial strategy missing") {
DialStrategy::Parallel(targets) => {
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].aor.to_string(), "sip:agent1@pbx.example");
}
other => panic!("unexpected strategy: {:?}", other),
}
assert_eq!(plan.ring_timeout, Some(std::time::Duration::from_secs(12)));
}
#[test]
fn test_queue_strategy_builds_skill_group_targets() {
let queue_cfg = RouteQueueConfig {
strategy: RouteQueueStrategyConfig {
mode: QueueDialMode::Sequential,
wait_timeout_secs: Some(15),
targets: vec![
RouteQueueTargetConfig {
uri: "sip:agent1@pbx.example".to_string(),
label: Some("Agent 1".to_string()),
},
RouteQueueTargetConfig {
uri: "skill-group:support_l1".to_string(),
label: Some("Support L1".to_string()),
},
],
},
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
match plan.dial_strategy.expect("dial strategy missing") {
DialStrategy::Sequential(targets) => {
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].aor.to_string(), "sip:agent1@pbx.example");
assert_eq!(targets[1].aor.to_string(), "skill-group:support_l1");
assert_eq!(
targets[1].contact_raw,
Some("skill-group:support_l1".to_string())
);
}
other => panic!("unexpected strategy: {:?}", other),
}
}
#[test]
fn test_queue_fallback_with_skill_group() {
let queue_cfg = RouteQueueConfig {
fallback: Some(RouteQueueFallbackConfig {
redirect: None,
failure_code: None,
failure_reason: None,
failure_prompt: None,
queue_ref: Some("skill-group:support_l2".to_string()),
skill_group_ref: None,
}),
..RouteQueueConfig::default()
};
let plan = queue_cfg
.to_queue_plan()
.expect("queue config should convert to plan");
match plan.fallback.expect("fallback missing") {
QueueFallbackAction::Queue { name } => {
assert_eq!(name, "skill-group:support_l2");
}
other => panic!("unexpected fallback action: {:?}", other),
}
}
#[tokio::test]
async fn test_match_invite_reject_rule() {
let routing_state = Arc::new(RoutingState::new());
let routes = vec![RouteRule {
name: "emergency_reject".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
to_user: Some("^(110|120|119)$".to_string()),
..Default::default()
},
rewrite: None,
action: RouteAction {
action: Some("reject".to_string()),
dest: None,
select: "rr".to_string(),
hash_key: None,
reject: Some(RejectConfig {
code: 403,
reason: Some("Emergency calls not allowed".to_string()),
headers: HashMap::new(),
}),
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:110@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let trunks = HashMap::new();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Abort(code, reason) => {
assert_eq!(code, rsipstack::sip::StatusCode::Forbidden);
assert_eq!(reason, Some("Emergency calls not allowed".to_string()));
}
RouteResult::Forward(_, _) => panic!("Expected abort, got forward"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_rewrite_rules() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"trunk1".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "rewrite_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
from_user: Some("^\\+86(1\\d{10})$".to_string()),
..Default::default()
},
rewrite: Some(RewriteRules {
from_user: Some("0{1}".to_string()), ..Default::default()
}),
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("trunk1".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let mut option = create_test_invite_option();
option.caller = "sip:+8613812345678@rustpbx.com".try_into().unwrap();
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(option, _) => {
let caller_user = option.caller.user().unwrap_or_default();
assert_eq!(caller_user, "013812345678");
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_load_balancing() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"trunk1".to_string(),
TrunkConfig {
dest: "sip:gateway1.rustpbx.com:5060".to_string(),
..Default::default()
},
);
trunks.insert(
"trunk2".to_string(),
TrunkConfig {
dest: "sip:gateway2.rustpbx.com:5060".to_string(),
..Default::default()
},
);
trunks.insert(
"trunk3".to_string(),
TrunkConfig {
dest: "sip:gateway3.rustpbx.com:5060".to_string(),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "load_balance_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
to_user: Some("1001".to_string()),
..Default::default()
},
rewrite: None,
action: RouteAction {
action: None,
dest: Some(DestConfig::Multiple(vec![
"trunk1".to_string(),
"trunk2".to_string(),
"trunk3".to_string(),
])),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let _option = create_test_invite_option();
let origin = create_test_request();
let mut selected_destinations = Vec::new();
for _ in 0..5 {
let test_option = create_test_invite_option();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
test_option,
&origin,
None,
routing_state.clone(),
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(option, _) => {
assert!(option.destination.is_some());
let dest = option.destination.unwrap();
selected_destinations.push(dest.addr.to_string());
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
println!("Selected destinations: {:?}", selected_destinations);
}
#[tokio::test]
async fn test_match_invite_header_matching() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"vip_trunk".to_string(),
TrunkConfig {
dest: "sip:vip.gateway.com:5060".to_string(),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "vip_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
headers: {
let mut headers = HashMap::new();
headers.insert("header.X-VIP".to_string(), "gold".to_string());
headers
},
..Default::default()
},
rewrite: None,
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("vip_trunk".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_sip_request(
rsipstack::sip::Method::Invite,
"sip:1001@rustpbx.com",
"Alice <sip:alice@rustpbx.com>",
"Bob <sip:1001@rustpbx.com>",
&format!("{}@rustpbx.com", generate_random_string(8)),
1,
Some(vec![rsipstack::sip::Header::Other(
"X-VIP".to_string(),
"gold".to_string(),
)]),
);
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(_option, _) => {
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_default_route() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"default".to_string(),
TrunkConfig {
dest: "sip:default.gateway.com:5060".to_string(),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "non_matching_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
to_user: Some("^999\\d+$".to_string()), ..Default::default()
},
rewrite: None,
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("trunk1".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_test_invite_option();
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(option, _) => {
println!("Default route selected: {:?}", option.destination);
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => {
}
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_advanced_rewrite_patterns() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"test_trunk".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
..Default::default()
},
);
let routes_config_us = vec![RouteRule {
name: "us_rewrite_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
from_user: Some("^\\+1(\\d{10})$".to_string()),
..Default::default()
},
rewrite: Some(RewriteRules {
from_user: Some("001{1}".to_string()),
..Default::default()
}),
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("test_trunk".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option_us = create_invite_option(
"sip:+15551234567@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin_us = create_test_request();
let result_us = match_invite(
Some(&trunks),
Some(&routes_config_us),
None,
option_us,
&origin_us,
None,
routing_state.clone(),
&DialDirection::Outbound,
)
.await
.unwrap();
match result_us {
RouteResult::Forward(option, _) => {
let caller_user = option.caller.user().unwrap_or_default();
assert_eq!(caller_user, "0015551234567");
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
let routes_config_digits = vec![RouteRule {
name: "digit_rewrite_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
from_user: Some("^(\\d+)$".to_string()),
..Default::default()
},
rewrite: Some(RewriteRules {
from_user: Some("ext{1}".to_string()),
..Default::default()
}),
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("test_trunk".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option_digits = create_invite_option(
"sip:12345@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin_digits = create_test_request();
let result_digits = match_invite(
Some(&trunks),
Some(&routes_config_digits),
None,
option_digits,
&origin_digits,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result_digits {
RouteResult::Forward(option, _) => {
let caller_user = option.caller.user().unwrap_or_default();
assert_eq!(caller_user, "ext12345");
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[tokio::test]
async fn test_match_invite_rewrite_from_host_uses_match_capture() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"trunk1".to_string(),
TrunkConfig {
dest: "sip:gateway.rustpbx.com:5060".to_string(),
rewrite_hostport: false, ..Default::default()
},
);
let routes = vec![RouteRule {
name: "host_rewrite_rule".to_string(),
description: None,
priority: 100,
match_conditions: MatchConditions {
from_user: Some("^\\+1(\\d{10})$".to_string()),
from_host: Some("^(gw-[a-z-]+)\\.provider\\.net$".to_string()),
..Default::default()
},
rewrite: Some(RewriteRules {
from_user: Some("001{1}".to_string()),
from_host: Some("proxy-{1}.internal".to_string()),
..Default::default()
}),
action: RouteAction {
action: None,
dest: Some(DestConfig::Single("trunk1".to_string())),
select: "rr".to_string(),
hash_key: None,
reject: None,
..Default::default()
},
disabled: None,
..Default::default()
}];
let option = create_invite_option(
"sip:+15551234567@gw-us-west.provider.net",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(option, _) => {
let caller_user = option.caller.user().unwrap_or_default();
assert_eq!(caller_user, "0015551234567");
let caller_host = option.caller.host().to_string();
assert_eq!(caller_host, "proxy-gw-us-west.internal");
}
RouteResult::Abort(_, _) => panic!("Expected forward, got abort"),
RouteResult::NotHandled(_, _) => panic!("Expected abort, got NotHandled"),
RouteResult::Queue { .. } => {
panic!("unexpected queue result")
}
RouteResult::Application { .. } => panic!("unexpected Application route in test"),
}
}
#[derive(Default)]
struct TestResourceLookup {
queues: HashMap<String, RouteQueueConfig>,
}
impl TestResourceLookup {
fn add_queue(&mut self, path: &str, config: RouteQueueConfig) {
self.queues.insert(path.to_string(), config);
}
}
#[async_trait]
impl RouteResourceLookup for TestResourceLookup {
async fn load_queue(&self, path: &str) -> anyhow::Result<Option<RouteQueueConfig>> {
Ok(self.queues.get(path).cloned())
}
}
fn create_invite_option(
caller: &str,
callee: &str,
contact: Option<&str>,
content_type: Option<&str>,
headers: Option<Vec<rsipstack::sip::Header>>,
) -> InviteOption {
let default_contact = format!(
"sip:{}@192.168.1.1:5060",
caller.split('@').next().unwrap_or("user")
);
let contact_uri = contact.unwrap_or(&default_contact);
InviteOption {
caller: caller.try_into().expect("Invalid caller URI"),
callee: callee.try_into().expect("Invalid callee URI"),
content_type: content_type.map(|s| s.to_string()),
offer: content_type
.filter(|ct| ct.contains("sdp"))
.map(|_| create_minimal_sdp(caller).into_bytes()),
contact: contact_uri.try_into().expect("Invalid contact URI"),
headers,
..Default::default()
}
}
fn create_sip_request(
method: rsipstack::sip::Method,
request_uri: &str,
from: &str,
to: &str,
call_id: &str,
cseq_num: u32,
additional_headers: Option<Vec<rsipstack::sip::Header>>,
) -> rsipstack::sip::Request {
let branch = format!("z9hG4bK{}", generate_random_string(16));
let from_tag = generate_random_string(8);
let mut headers = vec![
rsipstack::sip::Header::Via(
format!("SIP/2.0/UDP 192.168.1.1:5060;branch={}", branch)
.try_into()
.expect("Invalid Via header"),
),
rsipstack::sip::Header::From(
format!("{};tag={}", from, from_tag)
.try_into()
.expect("Invalid From header"),
),
rsipstack::sip::Header::To(to.into()),
rsipstack::sip::Header::CallId(call_id.into()),
rsipstack::sip::Header::CSeq(
format!("{} {}", cseq_num, method)
.try_into()
.expect("Invalid CSeq header"),
),
rsipstack::sip::Header::MaxForwards(70.into()),
];
if let Some(additional) = additional_headers {
headers.extend(additional);
}
let body = if method == rsipstack::sip::Method::Invite {
create_minimal_sdp(from).into_bytes()
} else {
Vec::new()
};
if !body.is_empty() {
headers.push(rsipstack::sip::Header::ContentType(
"application/sdp".into(),
));
headers.push(rsipstack::sip::Header::ContentLength(
(body.len() as u32).into(),
));
}
rsipstack::sip::Request {
method,
uri: request_uri.try_into().expect("Invalid request URI"),
version: rsipstack::sip::Version::V2,
headers: headers.into(),
body,
}
}
fn create_minimal_sdp(user_part: &str) -> String {
let username = user_part.split('@').next().unwrap_or("user");
let session_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
format!(
"v=0\r\n\
o={} {} {} IN IP4 192.168.1.1\r\n\
s=Session\r\n\
c=IN IP4 192.168.1.1\r\n\
t=0 0\r\n\
m=audio 5004 RTP/AVP 0 8\r\n\
a=rtpmap:0 PCMU/8000\r\n\
a=rtpmap:8 PCMA/8000\r\n",
username, session_id, session_id
)
}
fn generate_random_string(length: usize) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{:x}", timestamp).chars().take(length).collect()
}
fn create_test_invite_option() -> InviteOption {
create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
)
}
fn create_test_request() -> rsipstack::sip::Request {
create_sip_request(
rsipstack::sip::Method::Invite,
"sip:1001@rustpbx.com",
"Alice <sip:alice@rustpbx.com>",
"Bob <sip:1001@rustpbx.com>",
&format!("{}@rustpbx.com", generate_random_string(8)),
1,
None,
)
}
#[tokio::test]
async fn test_match_invite_application_action() {
let routing_state = Arc::new(RoutingState::new());
let routes = vec![RouteRule {
name: "voicemail_route".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("voicemail".to_string()),
..Default::default()
},
action: RouteAction {
action: Some("application".to_string()),
app: Some("voicemail".to_string()),
app_params: Some(serde_json::json!({"mailbox": "1001"})),
auto_answer: true,
..Default::default()
},
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:voicemail@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let trunks = HashMap::new();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Application {
app_name,
app_params,
auto_answer,
..
} => {
assert_eq!(app_name, "voicemail");
assert_eq!(app_params, Some(serde_json::json!({"mailbox": "1001"})));
assert!(auto_answer);
}
RouteResult::Forward(_, _) => panic!("Expected Application, got Forward"),
RouteResult::NotHandled(_, _) => panic!("Expected Application, got NotHandled"),
RouteResult::Abort(_, _) => panic!("Expected Application, got Abort"),
RouteResult::Queue { .. } => panic!("Expected Application, got Queue"),
}
}
#[tokio::test]
async fn test_match_invite_application_inferred_from_app_field() {
let routing_state = Arc::new(RoutingState::new());
let routes = vec![RouteRule {
name: "implicit_app_route".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("vm".to_string()),
..Default::default()
},
action: RouteAction {
app: Some("voicemail".to_string()),
app_params: None,
..Default::default()
},
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:vm@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let trunks = HashMap::new();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Application { app_name, .. } => {
assert_eq!(app_name, "voicemail");
}
_ => panic!("Expected Application"),
}
}
#[tokio::test]
async fn test_match_invite_application_auto_answer_false() {
let routing_state = Arc::new(RoutingState::new());
let routes = vec![RouteRule {
name: "custom_app_route".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("custom".to_string()),
..Default::default()
},
action: RouteAction {
action: Some("application".to_string()),
app: Some("custom_app".to_string()),
app_params: Some(serde_json::json!({"mode": "interactive"})),
auto_answer: false,
..Default::default()
},
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:custom@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let trunks = HashMap::new();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Application {
app_name,
auto_answer,
..
} => {
assert_eq!(app_name, "custom_app");
assert!(!auto_answer);
}
_ => panic!("Expected Application"),
}
}
#[tokio::test]
async fn test_match_invite_application_with_rewrite_headers() {
let routing_state = Arc::new(RoutingState::new());
let routes = vec![RouteRule {
name: "app_with_headers".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("ivrapp".to_string()),
..Default::default()
},
rewrite: Some(RewriteRules {
headers: vec![
("header.X-Custom".to_string(), "custom-value".to_string()),
("header.P-Asserted-Identity".to_string(), "<sip:routing@pbx.com>".to_string()),
]
.into_iter()
.collect(),
..Default::default()
}),
action: RouteAction {
action: Some("application".to_string()),
app: Some("ivr".to_string()),
app_params: Some(serde_json::json!({"file": "config/ivr/test.toml"})),
auto_answer: true,
..Default::default()
},
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:ivrapp@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let origin = create_test_request();
let trunks = HashMap::new();
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Application {
option,
app_name,
..
} => {
assert_eq!(app_name, "ivr");
let headers = option.headers.expect("routing should carry headers");
assert!(headers.iter().any(|h| {
h.name() == "X-Custom" && h.value() == "custom-value"
}));
assert!(headers.iter().any(|h| {
h.name() == "P-Asserted-Identity" && h.value() == "<sip:routing@pbx.com>"
}));
}
_ => panic!("Expected Application"),
}
}
#[test]
fn test_route_action_get_action_type_application() {
let action_explicit = RouteAction {
action: Some("application".to_string()),
app: Some("voicemail".to_string()),
..Default::default()
};
assert_eq!(
action_explicit.get_action_type(),
crate::proxy::routing::ActionType::Application
);
let action_implicit = RouteAction {
action: None,
app: Some("voicemail".to_string()),
..Default::default()
};
assert_eq!(
action_implicit.get_action_type(),
crate::proxy::routing::ActionType::Application
);
let action_forward = RouteAction {
action: None,
dest: Some(DestConfig::Single("trunk1".to_string())),
..Default::default()
};
assert_eq!(
action_forward.get_action_type(),
crate::proxy::routing::ActionType::Forward
);
}
#[tokio::test]
async fn test_apply_trunk_config_rewrites_callee_host() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com", None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.gateway.com:5060".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.host().to_string(),
"carrier.gateway.com",
"callee host should be rewritten to trunk's dest host"
);
assert_eq!(
option.callee.host_with_port.port,
Some(5060.into()),
"callee port should be set from trunk's dest port"
);
assert!(option.destination.is_some(), "destination should be set");
let dest = option.destination.unwrap();
assert_eq!(
dest.addr.host.to_string(),
"carrier.gateway.com",
"destination host should match trunk's dest"
);
assert!(option.credential.is_some(), "credential should be set");
let cred = option.credential.unwrap();
assert_eq!(cred.username, "user");
assert_eq!(cred.password, "pass");
}
#[tokio::test]
async fn test_apply_trunk_config_with_ipv6_dest() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:ipv6.carrier.com:5060".to_string(),
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.host().to_string(),
"ipv6.carrier.com",
"callee host should be rewritten to trunk dest"
);
}
#[tokio::test]
async fn test_apply_trunk_config_preserves_callee_user() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.com:5060".to_string(),
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.user().unwrap_or_default(),
"1001",
"callee user should be preserved during trunk rewrite"
);
}
#[tokio::test]
async fn test_match_invite_queue_with_trunk_rewrites_callee() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"wholesale_carrier".to_string(),
TrunkConfig {
dest: "sip:sip.carrier.com:5060".to_string(),
username: Some("carrier_user".to_string()),
password: Some("carrier_pass".to_string()),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "wholesale_route".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("^\\d+$".to_string()), ..Default::default()
},
action: RouteAction {
action: Some("queue".to_string()),
queue: Some("sales".to_string()),
dest: Some(DestConfig::Single("wholesale_carrier".to_string())),
..Default::default()
},
..Default::default()
}];
let mut lookup = TestResourceLookup::default();
lookup.add_queue(
"sales",
RouteQueueConfig {
name: Some("sales".to_string()),
accept_immediately: true,
..Default::default()
},
);
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:8613888888888@rustpbx.com", None,
Some("application/sdp"),
None,
);
let origin = create_sip_request(
rsipstack::sip::Method::Invite,
"sip:8613888888888@rustpbx.com",
"Alice <sip:alice@rustpbx.com>",
"Bob <sip:8613888888888@rustpbx.com>",
"test-call-id@rustpbx.com",
1,
None,
);
let result = match_invite(
Some(&trunks),
Some(&routes),
Some(&lookup),
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Queue { option, .. } => {
assert_eq!(
option.callee.host().to_string(),
"sip.carrier.com",
"callee host should be rewritten to trunk's sip_server"
);
assert!(
option.destination.is_some(),
"destination should be set for trunk routing"
);
assert!(
option.credential.is_some(),
"credential should be set from trunk config"
);
assert_eq!(
option.callee.user().unwrap_or_default(),
"8613888888888",
"callee user should be preserved"
);
}
_ => panic!("Expected Queue result, got non-queue result"),
}
}
#[tokio::test]
async fn test_match_invite_forward_with_trunk_rewrites_callee() {
let routing_state = Arc::new(RoutingState::new());
let mut trunks = HashMap::new();
trunks.insert(
"carrier1".to_string(),
TrunkConfig {
dest: "sip:10.0.0.1:5060".to_string(),
username: Some("user1".to_string()),
password: Some("pass1".to_string()),
..Default::default()
},
);
let routes = vec![RouteRule {
name: "route_to_carrier".to_string(),
priority: 100,
match_conditions: MatchConditions {
to_user: Some("^9\\d+$".to_string()), ..Default::default()
},
action: RouteAction {
dest: Some(DestConfig::Single("carrier1".to_string())),
select: "rr".to_string(),
..Default::default()
},
..Default::default()
}];
let option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:91234567890@rustpbx.com", None,
Some("application/sdp"),
None,
);
let origin = create_sip_request(
rsipstack::sip::Method::Invite,
"sip:91234567890@rustpbx.com",
"Alice <sip:alice@rustpbx.com>",
"Bob <sip:91234567890@rustpbx.com>",
"test-call-id@rustpbx.com",
1,
None,
);
let result = match_invite(
Some(&trunks),
Some(&routes),
None,
option,
&origin,
None,
routing_state,
&DialDirection::Outbound,
)
.await
.unwrap();
match result {
RouteResult::Forward(option, _) => {
assert_eq!(
option.callee.host().to_string(),
"10.0.0.1",
"callee host should be rewritten to trunk's IP"
);
assert_eq!(
option.callee.host_with_port.port,
Some(5060.into()),
"callee port should be set from trunk's dest"
);
assert!(option.destination.is_some(), "destination should be set");
assert_eq!(
option.callee.user().unwrap_or_default(),
"91234567890",
"callee user should be preserved during trunk rewrite"
);
}
_ => panic!("Expected Forward result"),
}
}
#[tokio::test]
async fn test_trunk_transport_preserved_in_destination() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let trunk_tls = TrunkConfig {
dest: "sip:secure.carrier.com:5061".to_string(),
transport: Some("tls".to_string()),
..Default::default()
};
apply_trunk_config(&mut option, &trunk_tls).unwrap();
let dest = option.destination.unwrap();
assert_eq!(dest.r#type, Some(rsipstack::sip::transport::Transport::Tls));
}
#[test]
fn test_apply_trunk_config_adds_p_asserted_identity() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@rustpbx.com",
"sip:1001@rustpbx.com",
None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.com:5060".to_string(),
username: Some("auth_user".to_string()),
password: Some("auth_pass".to_string()),
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert!(option.headers.is_some());
let headers = option.headers.unwrap();
let has_pai = headers.iter().any(|h| {
if let rsipstack::sip::Header::Other(name, value) = h {
name.to_lowercase() == "p-asserted-identity" && value.contains("sip:alice@rustpbx.com")
} else {
false
}
});
assert!(
has_pai,
"P-Asserted-Identity header should be added when trunk has username"
);
}
#[tokio::test]
async fn test_apply_trunk_config_rewrite_hostport_true() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@original.com",
"sip:1001@original.com:5060", None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.gateway.com:5080".to_string(),
rewrite_hostport: true,
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.host().to_string(),
"carrier.gateway.com",
"callee host should be rewritten when rewrite_hostport is true"
);
assert_eq!(
option.callee.host_with_port.port,
Some(5080.into()),
"callee port should be rewritten when rewrite_hostport is true"
);
assert_eq!(
option.caller.host().to_string(),
"carrier.gateway.com",
"caller host should be rewritten when rewrite_hostport is true"
);
assert_eq!(
option.caller.host_with_port.port,
Some(5080.into()),
"caller port should be rewritten when rewrite_hostport is true"
);
assert!(option.destination.is_some());
}
#[tokio::test]
async fn test_apply_trunk_config_rewrite_hostport_false() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@original.com",
"sip:1001@original.com:5060", None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.gateway.com:5080".to_string(),
rewrite_hostport: false,
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.host().to_string(),
"original.com",
"callee host should NOT be rewritten when rewrite_hostport is false"
);
assert_eq!(
option.callee.host_with_port.port,
Some(5060.into()),
"callee port should NOT be rewritten when rewrite_hostport is false"
);
assert_eq!(
option.caller.host().to_string(),
"original.com",
"caller host should NOT be rewritten when rewrite_hostport is false"
);
assert_eq!(
option.caller.host_with_port.port, None,
"caller port should NOT be rewritten when rewrite_hostport is false"
);
assert!(option.destination.is_some());
let dest = option.destination.unwrap();
assert_eq!(
dest.addr.host.to_string(),
"carrier.gateway.com",
"destination should still be set to trunk's dest"
);
}
#[tokio::test]
async fn test_apply_trunk_config_rewrite_hostport_default() {
use crate::proxy::routing::matcher::apply_trunk_config;
let mut option = create_invite_option(
"sip:alice@original.com",
"sip:1001@original.com:5060",
None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.gateway.com:5080".to_string(),
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(
option.callee.host().to_string(),
"carrier.gateway.com",
"callee host should be rewritten by default (rewrite_hostport defaults to true)"
);
}
#[tokio::test]
async fn test_apply_trunk_config_rewrite_hostport_preserves_user() {
use crate::proxy::routing::matcher::apply_trunk_config;
let test_cases = vec![
(
true,
"callee user should be preserved with rewrite_hostport=true",
),
(
false,
"callee user should be preserved with rewrite_hostport=false",
),
];
for (rewrite, msg) in test_cases {
let mut option = create_invite_option(
"sip:alice@original.com",
"sip:12345@original.com:5060",
None,
Some("application/sdp"),
None,
);
let trunk = TrunkConfig {
dest: "sip:carrier.com:5080".to_string(),
rewrite_hostport: rewrite,
..Default::default()
};
apply_trunk_config(&mut option, &trunk).unwrap();
assert_eq!(option.callee.user().unwrap_or_default(), "12345", "{}", msg);
}
}