#[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
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent1@example.com"),
)
.await;
stack.join().await.expect("should exit after transfer");
}
#[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!({}));
tokio::time::sleep(Duration::from_millis(50)).await;
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent2@example.com"}),
);
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent2@example.com"),
)
.await;
stack.join().await.expect("should exit after transfer");
}
#[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.custom("agent_no_answer", serde_json::json!({}));
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent3@example.com"}),
);
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent3@example.com"),
)
.await;
stack.join().await.expect("should complete successfully");
}
#[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));
stack.custom(
"agent_connected",
serde_json::json!({"agent_uri": "sip:agent1@example.com", "agent_id": "agent-001"}),
);
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent1@example.com"),
)
.await;
let agent = agent_registry.get_agent("agent-001").await.unwrap();
assert!(matches!(agent.presence, PresenceState::Busy));
stack.join().await.expect("should complete successfully");
}
#[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.assert_cmd(200, "Transfer", |c| {
matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent1@example.com")
}).await;
stack
.join()
.await
.expect("should exit after transfer with 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.assert_cmd(200, "Transfer", |c| {
matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent1@example.com")
}).await;
stack
.join()
.await
.expect("should exit after direct transfer");
}
#[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.custom("agent_busy", serde_json::json!({}));
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.assert_cmd(200, "Transfer", |c| {
matches!(c, CallCommand::Transfer { target, .. } if target == "sip:agent1@example.com")
}).await;
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.custom("agent_no_answer", serde_json::json!({}));
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.custom("agent_no_answer", serde_json::json!({}));
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;
}
}