#[cfg(test)]
mod tests {
use crate::call::app::CallApp;
use crate::call::app::agent_registry::AgentRegistry;
use crate::call::app::queue::{QueueApp, QueueConfig};
use crate::call::app::testing::MockCallStack;
use crate::call::domain::CallCommand;
use crate::call::{
DialStrategy, FailureAction, Location, QueueFallbackAction, QueueHoldConfig, QueuePlan,
VoicePrompts,
};
use rsipstack::sip::Uri;
use std::time::Duration;
fn build_simple_queue_config() -> QueueConfig {
let agent_uri = Uri::try_from("sip:agent1@example.com").unwrap();
let location = Location {
aor: agent_uri,
expires: 3600,
destination: None,
last_modified: None,
supports_webrtc: false,
credential: None,
headers: None,
registered_aor: None,
contact_raw: None,
contact_params: None,
path: None,
service_route: None,
instance_id: None,
gruu: None,
temp_gruu: None,
reg_id: None,
transport: None,
user_agent: None,
home_proxy: None,
};
QueueConfig {
name: "test-queue".to_string(),
accept_immediately: true,
hold: Some(QueueHoldConfig {
audio_file: Some("sounds/hold_music.wav".to_string()),
loop_playback: true,
}),
fallback: Some(QueueFallbackAction::Failure(FailureAction::Hangup {
code: Some(rsipstack::sip::StatusCode::TemporarilyUnavailable),
reason: Some("All agents busy".to_string()),
})),
agents: vec![location.clone()],
strategy: DialStrategy::Sequential(vec![location]),
ring_timeout: Some(Duration::from_secs(30)),
..Default::default()
}
}
fn build_simple_queue() -> QueuePlan {
build_simple_queue_config().to_plan()
}
fn build_sequential_queue_config() -> QueueConfig {
let agents: Vec<Location> = vec![
"sip:agent1@example.com",
"sip:agent2@example.com",
"sip:agent3@example.com",
]
.into_iter()
.map(|uri| Location {
aor: Uri::try_from(uri).unwrap(),
expires: 3600,
destination: None,
last_modified: None,
supports_webrtc: false,
credential: None,
headers: None,
registered_aor: None,
contact_raw: None,
contact_params: None,
path: None,
service_route: None,
instance_id: None,
gruu: None,
temp_gruu: None,
reg_id: None,
transport: None,
user_agent: None,
home_proxy: None,
})
.collect();
QueueConfig {
name: "sequential-queue".to_string(),
accept_immediately: true,
hold: Some(QueueHoldConfig {
audio_file: Some("sounds/hold_music.wav".to_string()),
loop_playback: true,
}),
fallback: Some(QueueFallbackAction::Failure(FailureAction::Hangup {
code: Some(rsipstack::sip::StatusCode::TemporarilyUnavailable),
reason: Some("All agents busy".to_string()),
})),
agents: agents.clone(),
strategy: DialStrategy::Sequential(agents),
ring_timeout: Some(Duration::from_secs(30)),
..Default::default()
}
}
fn build_sequential_queue() -> QueuePlan {
build_sequential_queue_config().to_plan()
}
#[allow(dead_code)]
fn build_parallel_queue_config() -> QueueConfig {
let agents: Vec<Location> = vec!["sip:agent1@example.com", "sip:agent2@example.com"]
.into_iter()
.map(|uri| Location {
aor: Uri::try_from(uri).unwrap(),
expires: 3600,
destination: None,
last_modified: None,
supports_webrtc: false,
credential: None,
headers: None,
registered_aor: None,
contact_raw: None,
contact_params: None,
path: None,
service_route: None,
instance_id: None,
gruu: None,
temp_gruu: None,
reg_id: None,
transport: None,
user_agent: None,
home_proxy: None,
})
.collect();
QueueConfig {
name: "parallel-queue".to_string(),
accept_immediately: true,
hold: Some(QueueHoldConfig {
audio_file: Some("sounds/hold_music.wav".to_string()),
loop_playback: true,
}),
fallback: Some(QueueFallbackAction::Failure(FailureAction::Hangup {
code: Some(rsipstack::sip::StatusCode::TemporarilyUnavailable),
reason: Some("All agents busy".to_string()),
})),
agents: agents.clone(),
strategy: DialStrategy::Parallel(agents),
ring_timeout: Some(Duration::from_secs(30)),
..Default::default()
}
}
#[allow(dead_code)]
fn build_parallel_queue() -> QueuePlan {
build_parallel_queue_config().to_plan()
}
#[tokio::test]
async fn test_queue_basic_enter() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_queue_no_immediate_answer() {
let mut plan = build_simple_queue();
plan.accept_immediately = false;
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_queue_no_agents_fallback() {
let mut plan = build_simple_queue();
plan.dial_strategy = Some(DialStrategy::Sequential(vec![]));
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_no_agents_no_fallback_returns_busy() {
let mut plan = build_simple_queue();
plan.dial_strategy = Some(DialStrategy::Sequential(vec![]));
plan.fallback = None;
let config = QueueConfig {
name: "test-queue".to_string(),
accept_immediately: true,
hold: None,
fallback: None,
agents: vec![],
strategy: DialStrategy::Sequential(vec![]),
..Default::default()
};
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_play_then_hangup_fallback() {
let mut plan = build_simple_queue();
plan.fallback = Some(QueueFallbackAction::Failure(
FailureAction::PlayThenHangup {
audio_file: "sounds/all_busy.wav".to_string(),
use_early_media: false,
status_code: rsipstack::sip::StatusCode::TemporarilyUnavailable,
reason: Some("All agents are busy".to_string()),
},
));
plan.dial_strategy = Some(DialStrategy::Sequential(vec![]));
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_hold_music_completes() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.audio_complete("default");
tokio::time::sleep(Duration::from_millis(50)).await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_queue_remote_hangup() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.remote_hangup();
stack
.join()
.await
.expect("should exit cleanly on remote hangup");
}
#[tokio::test]
async fn test_queue_agent_connected_event() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com"}),
);
stack
.join()
.await
.expect("should exit after agent connected");
}
#[tokio::test]
async fn test_queue_agent_busy_retry() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent2@example.com"}),
);
stack
.join()
.await
.expect("should exit after agent connected");
}
#[tokio::test]
async fn test_queue_all_agents_busy_fallback() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("all_agents_busy", serde_json::json!({}));
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_redirect_fallback() {
let mut plan = build_simple_queue();
plan.dial_strategy = Some(DialStrategy::Sequential(vec![]));
plan.fallback = Some(QueueFallbackAction::Redirect {
target: Uri::try_from("sip:backup@example.com").unwrap(),
});
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "sip:backup@example.com"),
)
.await;
stack.join().await.expect("should exit after redirect");
}
#[tokio::test]
async fn test_queue_to_queue_fallback() {
let mut plan = build_simple_queue();
plan.dial_strategy = Some(DialStrategy::Sequential(vec![]));
plan.fallback = Some(QueueFallbackAction::Queue {
name: "overflow".to_string(),
});
let config = build_simple_queue_config();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "queue:overflow"),
)
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
#[tokio::test]
async fn test_queue_no_hold_music() {
let mut plan = build_simple_queue();
plan.hold = None;
let config = build_simple_queue_config();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_queue_app_name() {
let plan = build_simple_queue();
let config = build_simple_queue_config();
let app = QueueApp::new(plan.clone(), config);
assert_eq!(app.name(), "test-queue");
assert_eq!(app.app_type(), crate::call::app::CallAppType::Queue);
}
#[tokio::test]
async fn test_queue_app_name_default() {
let mut plan = build_simple_queue();
plan.label = None;
let config = build_simple_queue_config();
let app = QueueApp::new(plan, config);
assert_eq!(app.name(), "queue");
}
#[test]
fn test_queue_config_to_plan() {
let config = QueueConfig {
name: "sales".to_string(),
accept_immediately: true,
hold: Some(crate::call::QueueHoldConfig {
audio_file: Some("hold.wav".to_string()),
loop_playback: true,
}),
fallback: Some(QueueFallbackAction::Failure(FailureAction::Hangup {
code: Some(rsipstack::sip::StatusCode::TemporarilyUnavailable),
reason: None,
})),
agents: vec![Location {
aor: Uri::try_from("sip:agent@example.com").unwrap(),
expires: 3600,
destination: None,
last_modified: None,
supports_webrtc: false,
credential: None,
headers: None,
registered_aor: None,
contact_raw: None,
contact_params: None,
path: None,
service_route: None,
instance_id: None,
gruu: None,
temp_gruu: None,
reg_id: None,
transport: None,
user_agent: None,
home_proxy: None,
}],
strategy: DialStrategy::Sequential(vec![]),
ring_timeout: Some(Duration::from_secs(60)),
..Default::default()
};
let plan = config.to_plan();
assert_eq!(plan.label, Some("sales".to_string()));
assert!(plan.accept_immediately);
assert_eq!(plan.ring_timeout, Some(Duration::from_secs(60)));
}
#[tokio::test]
async fn test_queue_complex_scenario() {
let plan = build_sequential_queue();
let config = build_sequential_queue_config();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent3", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent3@example.com"}),
);
stack
.assert_cmd(200, "LegRemove-agent2", |c| {
matches!(c, CallCommand::LegRemove { .. })
})
.await;
stack
.join()
.await
.expect("should exit after agent connected");
}
#[tokio::test]
async fn test_autonomous_routing_with_agent_registry() {
use crate::call::app::agent_registry::{PresenceState, RoutingStrategy, db::DbRegistry};
use std::sync::Arc;
let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();
let agent_registry = Arc::new(DbRegistry::new(db));
agent_registry
.register(
"agent-001".to_string(),
"Alice".to_string(),
"sip:agent1@example.com".to_string(),
vec!["support".to_string()],
1,
)
.await
.unwrap();
agent_registry
.update_presence("agent-001", PresenceState::Available)
.await
.unwrap();
let mut config = build_simple_queue_config();
config.autonomous_routing = true;
config.skill_routing_enabled = true;
config.required_skills = vec!["support".to_string()];
config.routing_strategy = RoutingStrategy::LongestIdle;
config.agents = vec![]; config.strategy = DialStrategy::Sequential(vec![]);
let plan = config.to_plan();
let mut queue = QueueApp::new(plan, config);
queue = queue.with_agent_registry(agent_registry.clone());
queue = queue.with_call_id("call-001".to_string());
let mut stack = MockCallStack::run(Box::new(queue), "1001", "1002");
stack.enter().await;
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack
.assert_cmd(200, "OriginateCall", |c| {
matches!(c, CallCommand::LegAdd { target, .. } if target == "sip:agent1@example.com")
})
.await;
stack
.assert_cmd(200, "NotifyEvent", |c| {
matches!(c, CallCommand::InjectAppEvent { .. })
})
.await;
let agent = agent_registry.get_agent("agent-001").await.unwrap();
assert!(matches!(
agent.presence,
PresenceState::Ringing { call_id: Some(_) }
));
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com", "agent_id": "agent-001"}),
);
stack
.join()
.await
.expect("should exit after agent connected");
let agent = agent_registry.get_agent("agent-001").await.unwrap();
assert!(matches!(
agent.presence,
PresenceState::Busy { call_id: None }
));
}
#[tokio::test]
async fn test_autonomous_routing_no_agents() {
use crate::call::app::agent_registry::db::DbRegistry;
use std::sync::Arc;
let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();
let agent_registry = Arc::new(DbRegistry::new(db));
let mut config = build_simple_queue_config();
config.autonomous_routing = true;
config.skill_routing_enabled = true;
config.required_skills = vec!["support".to_string()];
config.agents = vec![];
config.strategy = DialStrategy::Sequential(vec![]);
let plan = config.to_plan();
let mut queue = QueueApp::new(plan, config);
queue = queue.with_agent_registry(agent_registry);
let mut stack = MockCallStack::run(Box::new(queue), "1001", "1002");
stack.enter().await;
stack
.assert_cmd(480, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
let result: anyhow::Result<()> = stack.join().await;
result.expect("should complete successfully");
}
#[tokio::test]
async fn test_autonomous_routing_all_agents_busy_plays_busy_prompt() {
use crate::call::app::agent_registry::db::DbRegistry;
use std::sync::Arc;
let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();
let agent_registry = Arc::new(DbRegistry::new(db));
let mut config = build_simple_queue_config();
config.autonomous_routing = true;
config.skill_routing_enabled = false;
config.voice_prompts = Some(VoicePrompts::zh());
config.hold = None;
let plan = config.to_plan();
let mut queue = QueueApp::new(plan, config);
queue = queue.with_agent_registry(agent_registry);
let mut stack = MockCallStack::run(Box::new(queue), "1001", "1002");
stack.enter().await;
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-busy-auto", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup-auto", |c| matches!(c, CallCommand::Hangup(_)))
.await;
stack.join().await.expect("should complete successfully");
}
#[tokio::test]
async fn test_skill_routing_no_agents_plays_busy_prompt() {
let mut config = build_simple_queue_config();
config.skill_routing_enabled = true;
config.required_skills = vec!["support".to_string()];
config.agents = vec![];
config.strategy = DialStrategy::Sequential(vec![]);
config.voice_prompts = Some(VoicePrompts::zh());
config.hold = None;
let plan = config.to_plan();
let queue = QueueApp::new(plan, config);
let mut stack = MockCallStack::run(Box::new(queue), "1001", "1002");
stack.enter().await;
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-busy-skill", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup-skill", |c| matches!(c, CallCommand::Hangup(_)))
.await;
stack.join().await.expect("should complete successfully");
}
#[tokio::test]
async fn test_agent_ring_timeout() {
use crate::call::app::agent_registry::{PresenceState, RoutingStrategy, db::DbRegistry};
use std::sync::Arc;
let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();
let agent_registry = Arc::new(DbRegistry::new(db));
agent_registry
.register(
"agent-001".to_string(),
"Alice".to_string(),
"sip:agent1@example.com".to_string(),
vec!["support".to_string()],
1,
)
.await
.unwrap();
agent_registry
.update_presence("agent-001", PresenceState::Available)
.await
.unwrap();
let mut config = build_simple_queue_config();
config.autonomous_routing = true;
config.skill_routing_enabled = true;
config.required_skills = vec!["support".to_string()];
config.routing_strategy = RoutingStrategy::LongestIdle;
config.ring_timeout = Some(Duration::from_millis(100));
config.agents = vec![];
config.strategy = DialStrategy::Sequential(vec![]);
let plan = config.to_plan();
let mut queue = QueueApp::new(plan, config);
queue = queue.with_agent_registry(agent_registry.clone());
queue = queue.with_call_id("call-001".to_string());
let mut stack = MockCallStack::run(Box::new(queue), "1001", "1002");
stack.enter().await;
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack
.assert_cmd(200, "OriginateCall", |c| {
matches!(c, CallCommand::LegAdd { target, .. } if target == "sip:agent1@example.com")
})
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.timeout("agent_ring_timeout");
let cmds = stack.drain_cmds();
let has_no_answer = cmds
.iter()
.any(|c| matches!(c, CallCommand::InjectAppEvent { .. }));
assert!(has_no_answer, "Expected queue.agent_no_answer event");
let has_hangup = cmds.iter().any(|c| matches!(c, CallCommand::Hangup(_)));
assert!(has_hangup, "Expected Hangup after timeout");
let agent = agent_registry.get_agent("agent-001").await.unwrap();
assert!(matches!(agent.presence, PresenceState::Available));
let result: anyhow::Result<()> = stack.join().await;
result.expect("should complete successfully");
}
fn build_queue_config_with_prompts() -> QueueConfig {
let mut config = build_simple_queue_config();
config.voice_prompts = Some(VoicePrompts::zh());
config
}
#[tokio::test]
async fn test_queue_transfer_prompt() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_queue_config_with_prompts())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-hold", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com"}),
);
stack
.assert_cmd(200, "PlayPrompt-transfer", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.join()
.await
.expect("should exit after transfer prompt");
}
#[tokio::test]
async fn test_queue_no_prompts_transfers_directly() {
let plan = build_simple_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_simple_queue_config())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com"}),
);
stack
.join()
.await
.expect("should exit after agent connected");
}
#[tokio::test]
async fn test_queue_busy_prompt_all_agents_busy() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_queue_config_with_prompts())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("all_agents_busy", serde_json::json!({}));
stack
.assert_cmd(200, "PlayPrompt-busy", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_busy_prompt_agent_exhaustion() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_queue_config_with_prompts())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent3", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "PlayPrompt-busy", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_transfer_prompt_english() {
let mut config = build_simple_queue_config();
config.voice_prompts = Some(VoicePrompts::en());
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com"}),
);
stack
.assert_cmd(200, "PlayPrompt-en-transfer", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.join()
.await
.expect("should exit after english transfer prompt");
}
#[tokio::test]
async fn test_queue_busy_prompt_max_wait_timeout() {
let mut config = build_queue_config_with_prompts();
config.max_wait_secs = 0;
let plan = config.to_plan();
let app = QueueApp::new(plan, config);
let mut stack = MockCallStack::run(Box::new(app), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.timeout("max_wait_timeout");
stack
.assert_cmd(200, "NotifyEvent", |c| {
matches!(c, CallCommand::InjectAppEvent { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-busy-timeout", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_no_answer_prompt_all_agents_noanswer() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_queue_config_with_prompts())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent3", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "PlayPrompt-noanswer", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_no_answer_prompt_fallback_to_busy_when_mixed() {
let plan = build_sequential_queue();
let mut stack = MockCallStack::run(
Box::new(QueueApp::new(plan, build_queue_config_with_prompts())),
"caller",
"1000",
);
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "LegAdd-agent3", |c| {
matches!(c, CallCommand::LegAdd { .. })
})
.await;
stack.custom("agent_busy", serde_json::json!({}));
stack
.assert_cmd(200, "PlayPrompt-busy-mixed", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_no_answer_prompt_without_config_fallsback_directly() {
let mut config = build_simple_queue_config();
config.voice_prompts = Some(VoicePrompts {
no_answer_prompt: None,
..VoicePrompts::zh()
});
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.custom("agent_no_answer", serde_json::json!({}));
stack.custom("agent_no_answer", serde_json::json!({}));
stack.custom("agent_no_answer", serde_json::json!({}));
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_no_answer_prompt_ring_timeout() {
let mut config = build_queue_config_with_prompts();
config.max_wait_secs = 0;
let plan = config.to_plan();
let app = QueueApp::new(plan, config);
let mut stack = MockCallStack::run(Box::new(app), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.timeout("agent_ring_timeout");
stack
.assert_cmd(200, "PlayPrompt-noanswer-timeout", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_queue_parallel_originate_all_cancel_rest() {
let config = build_parallel_queue_config();
let plan = config.to_plan();
let agents = config.agents.clone();
assert_eq!(agents.len(), 2, "parallel queue should have 2 agents");
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
let cmd0 = stack.next_cmd(200).await.expect("LegAdd for agent1");
let _leg_id_0 = match &cmd0 {
CallCommand::LegAdd { leg_id, .. } => {
leg_id.clone().expect("LegAdd should have leg_id")
}
_ => panic!("expected LegAdd, got {cmd0:?}"),
};
let cmd1 = stack.next_cmd(200).await.expect("LegAdd for agent2");
let leg_id_1 = match &cmd1 {
CallCommand::LegAdd { leg_id, .. } => {
leg_id.clone().expect("LegAdd should have leg_id")
}
_ => panic!("expected LegAdd, got {cmd1:?}"),
};
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com", "agent_id": "agent-001"}),
);
let remove = stack.next_cmd(200).await.expect("LegRemove");
match &remove {
CallCommand::LegRemove { leg_id } => {
assert_eq!(
format!("{leg_id:?}"),
format!("{leg_id_1:?}"),
"should remove the non-answering agent (agent 2)"
);
}
other => panic!("expected LegRemove, got {other:?}"),
}
stack
.join()
.await
.expect("should exit after agent connected (parallel)");
}
#[tokio::test]
async fn test_queue_parallel_all_agents_busy_fallback() {
let mut config = build_parallel_queue_config();
config.fallback = Some(QueueFallbackAction::Failure(FailureAction::Hangup {
code: Some(rsipstack::sip::StatusCode::TemporarilyUnavailable),
reason: Some("All agents busy".to_string()),
}));
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack
.assert_cmd(200, "LegAdd-agent1", |c| {
matches!(c, CallCommand::LegAdd { target, .. } if target == "sip:agent1@example.com")
})
.await;
stack
.assert_cmd(200, "LegAdd-agent2", |c| {
matches!(c, CallCommand::LegAdd { target, .. } if target == "sip:agent2@example.com")
})
.await;
stack.timeout("agent_ring_timeout");
stack
.assert_cmd(200, "FallbackHangup", |c| {
matches!(c, CallCommand::Hangup(_))
})
.await;
}
#[tokio::test]
async fn test_callback_dtmf_request() {
let mut config = build_simple_queue_config();
config.callback_request_enabled = true;
config.callback_offer_after_secs = 0;
config.callback_dtmf_key = "2".to_string();
config.voice_prompts = Some(VoicePrompts {
callback_confirm_prompt: Some("callback-confirm.wav".into()),
..VoicePrompts::zh()
});
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayHold", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.dtmf("2");
stack
.assert_cmd(200, "PlayCallbackConfirm", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
stack.join().await.unwrap();
}
#[tokio::test]
async fn test_callback_dtmf_before_offer_time_ignored() {
let mut config = build_simple_queue_config();
config.callback_request_enabled = true;
config.callback_offer_after_secs = 60; config.callback_dtmf_key = "2".to_string();
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayHold", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.dtmf("2");
let timeout = tokio::time::sleep(Duration::from_millis(300));
tokio::pin!(timeout);
loop {
tokio::select! {
() = &mut timeout => break,
cmd = stack.next_cmd(100) => {
if matches!(cmd, Some(CallCommand::Hangup(_))) {
panic!("Callback should not trigger before offer time");
}
}
}
}
}
#[tokio::test]
async fn test_callback_disabled_ignores_dtmf() {
let mut config = build_simple_queue_config();
config.callback_request_enabled = false; config.callback_dtmf_key = "2".to_string();
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayHold", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.dtmf("2");
let timeout = tokio::time::sleep(Duration::from_millis(300));
tokio::pin!(timeout);
loop {
tokio::select! {
() = &mut timeout => break,
cmd = stack.next_cmd(100) => {
if matches!(cmd, Some(CallCommand::Hangup(_))) {
panic!("Callback disabled — Hangup should not occur");
}
}
}
}
}
#[tokio::test]
async fn test_callback_wrong_dtmf_key_ignored() {
let mut config = build_simple_queue_config();
config.callback_request_enabled = true;
config.callback_dtmf_key = "2".to_string(); let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayHold", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.dtmf("5");
let timeout = tokio::time::sleep(Duration::from_millis(300));
tokio::pin!(timeout);
loop {
tokio::select! {
() = &mut timeout => break,
cmd = stack.next_cmd(100) => {
if matches!(cmd, Some(CallCommand::Hangup(_))) {
panic!("Wrong DTMF key — Hangup should not occur");
}
}
}
}
}
#[tokio::test]
async fn test_callback_no_confirm_prompt_hangs_up_immediately() {
let mut config = build_simple_queue_config();
config.callback_request_enabled = true;
config.callback_offer_after_secs = 0;
config.callback_dtmf_key = "2".to_string();
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack
.assert_cmd(200, "PlayHold", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.dtmf("2");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
stack.join().await.unwrap();
}
#[tokio::test]
async fn test_final_destination_prompt_no_agents_plays_prompt() {
let mut config = QueueConfig::default(); config.voice_prompts = Some(VoicePrompts {
busy_prompt: None,
final_destination_prompt: Some("final-dest.wav".into()),
..VoicePrompts::zh()
});
let plan = config.to_plan();
let mut stack = MockCallStack::run(Box::new(QueueApp::new(plan, config)), "caller", "1000");
stack
.assert_cmd(200, "PlayFinalPrompt", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
stack.join().await.unwrap();
}
#[tokio::test]
async fn test_cumulative_escalation_does_not_crash() {
use crate::call::app::agent_registry::db::DbRegistry;
use crate::call::app::queue::EscalationMode;
use std::sync::Arc;
let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();
let registry = Arc::new(DbRegistry::new(db));
registry
.register(
"agent1".into(),
"Agent 1".into(),
"sip:agent1@pbx".into(),
vec!["support".into()],
1,
)
.await
.unwrap();
registry
.update_presence(
"agent1",
crate::call::app::agent_registry::PresenceState::Available,
)
.await
.unwrap();
let mut config = build_simple_queue_config();
config.autonomous_routing = true;
config.skill_routing_enabled = true;
config.required_skills = vec!["support".to_string()];
config.agents = vec![];
config.strategy = DialStrategy::Sequential(vec![]);
config.escalation_mode = EscalationMode::Cumulative;
config.escalation_timeline = vec![crate::call::app::queue::EscalationStep {
threshold_secs: 5,
add_skill_group: "support2".to_string(),
}];
let plan = config.to_plan();
let mut queue = QueueApp::new(plan, config);
queue = queue.with_agent_registry(registry.clone());
queue = queue.with_call_id("call-001".to_string());
let mut stack = MockCallStack::run(Box::new(queue), "caller", "1000");
stack
.assert_cmd(200, "Answer", |c| matches!(c, CallCommand::Answer { .. }))
.await;
stack.timeout("escalation_check");
stack.join().await.unwrap();
}
}