#[cfg(test)]
mod tests {
use crate::call::app::ivr::IvrApp;
use crate::call::app::ivr_config::{
EntryAction, IvrDefinition, IvrFileConfig, MenuEntry, MenuNode,
};
use crate::call::app::testing::MockCallStack;
use crate::call::domain::CallCommand;
use crate::call::domain::MediaSource;
use crate::media::Track;
use std::collections::HashMap;
use std::time::Duration;
fn build_simple_ivr() -> IvrDefinition {
IvrDefinition {
name: "test-ivr".to_string(),
description: Some("Test IVR".to_string()),
lang: Some("en".to_string()),
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: "sounds/welcome.wav".to_string(),
timeout_ms: 200, max_retries: 2,
invalid_prompt: Some("sounds/invalid.wav".to_string()),
timeout_action: Some(EntryAction::Repeat),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![
MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
},
MenuEntry {
key: "2".to_string(),
label: Some("Support".to_string()),
action: EntryAction::Menu {
menu: "support".to_string(),
},
},
MenuEntry {
key: "3".to_string(),
label: Some("Address".to_string()),
action: EntryAction::Play {
prompt: "sounds/address.wav".to_string(),
prompt_text: None,
prompt_voice: None,
},
},
MenuEntry {
key: "*".to_string(),
label: Some("Repeat".to_string()),
action: EntryAction::Repeat,
},
MenuEntry {
key: "0".to_string(),
label: Some("Hangup".to_string()),
action: EntryAction::Hangup {
prompt: Some("sounds/goodbye.wav".to_string()),
prompt_text: None,
prompt_voice: None,
},
},
],
..Default::default()
},
menus: {
let mut m = HashMap::new();
m.insert(
"support".to_string(),
MenuNode {
greeting: "sounds/support_menu.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Transfer {
target: "3000".to_string(),
}),
max_retries_action: Some(EntryAction::Transfer {
target: "3000".to_string(),
}),
entries: vec![
MenuEntry {
key: "1".to_string(),
label: Some("Billing".to_string()),
action: EntryAction::Transfer {
target: "3001".to_string(),
},
},
MenuEntry {
key: "9".to_string(),
label: Some("Back".to_string()),
action: EntryAction::Menu {
menu: "root".to_string(),
},
},
],
..Default::default()
},
);
m
},
}
}
#[tokio::test]
async fn test_ivr_basic_enter() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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(30)).await;
assert!(
stack.drain_cmds().is_empty(),
"should be idle waiting for DTMF"
);
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_dtmf_transfer() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack.join().await.expect("loop should exit after transfer");
}
#[tokio::test]
async fn test_ivr_submenu_navigation() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("2");
stack
.assert_cmd(200, "PlayPrompt-support", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.join()
.await
.expect("loop should exit after transfer from sub-menu");
}
#[tokio::test]
async fn test_ivr_back_to_root() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("2");
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.audio_complete("default");
stack.dtmf("9");
stack
.assert_cmd(200, "PlayPrompt-root", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_back_action_pops_one_level() {
let ivr = IvrDefinition {
name: "back-test".to_string(),
root: MenuNode {
greeting: "sounds/root.wav".to_string(),
entries: vec![MenuEntry {
key: "1".to_string(),
label: None,
action: EntryAction::Menu {
menu: "sub_a".to_string(),
},
}],
..Default::default()
},
menus: {
let mut m = HashMap::new();
m.insert(
"sub_a".to_string(),
MenuNode {
greeting: "sounds/sub_a.wav".to_string(),
entries: vec![
MenuEntry {
key: "1".to_string(),
label: None,
action: EntryAction::Menu {
menu: "sub_b".to_string(),
},
},
MenuEntry {
key: "9".to_string(),
label: Some("Back".to_string()),
action: EntryAction::Back,
},
],
..Default::default()
},
);
m.insert(
"sub_b".to_string(),
MenuNode {
greeting: "sounds/sub_b.wav".to_string(),
entries: vec![MenuEntry {
key: "9".to_string(),
label: Some("Back".to_string()),
action: EntryAction::Back,
}],
..Default::default()
},
);
m
},
..Default::default()
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-root", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(200, "PlayPrompt-sub_a", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(200, "PlayPrompt-sub_b", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("9");
stack
.assert_cmd(200, "PlayPrompt-sub_a-2", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_back_at_root_is_noop() {
let ivr = IvrDefinition {
name: "back-root-test".to_string(),
root: MenuNode {
greeting: "sounds/root.wav".to_string(),
entries: vec![MenuEntry {
key: "9".to_string(),
label: Some("Back".to_string()),
action: EntryAction::Back,
}],
..Default::default()
},
menus: HashMap::new(),
..Default::default()
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-root", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("9");
stack
.assert_cmd(200, "PlayPrompt-root-2", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_play_returns_to_menu() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("3");
stack
.assert_cmd(200, "PlayPrompt-address", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "PlayPrompt-root", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_repeat_action() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("*");
stack
.assert_cmd(200, "PlayPrompt-repeat", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_hangup_with_prompt() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("0");
stack
.assert_cmd(200, "PlayPrompt-goodbye", |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_ivr_invalid_key() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("7");
stack
.assert_cmd(200, "PlayPrompt-invalid", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_timeout_and_max_retries() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack
.assert_cmd(500, "PlayPrompt-retry1", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(500, "PlayPrompt-retry2", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(500, "Hangup-max-retries", |c| {
matches!(c, CallCommand::Hangup(_))
})
.await;
}
#[tokio::test]
async fn test_ivr_toml_parsing() {
let toml_str = r#"
[ivr]
name = "toml-test"
[ivr.root]
greeting = "hello.wav"
timeout_ms = 100
max_retries = 1
max_retries_action = { type = "hangup" }
[[ivr.root.entries]]
key = "1"
action = { type = "transfer", target = "100" }
"#;
let config: IvrFileConfig = toml::from_str(toml_str).expect("parse");
config.ivr.validate().expect("valid");
let mut stack = MockCallStack::run(Box::new(IvrApp::new(config.ivr)), "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");
stack.dtmf("1");
stack.join().await.expect("transfer exit");
}
#[tokio::test]
async fn test_ivr_remote_hangup() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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_ivr_submenu_timeout_transfer() {
let ivr = build_simple_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("2");
stack
.assert_cmd(200, "PlayPrompt", |c| matches!(c, CallCommand::Play { .. }))
.await;
stack.audio_complete("default");
stack.join().await.expect("should transfer on timeout");
}
fn build_collect_extension_ivr() -> IvrDefinition {
IvrDefinition {
name: "test-collect".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: "sounds/collect_menu.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![
MenuEntry {
key: "1".to_string(),
label: Some("Dial Extension".to_string()),
action: EntryAction::CollectExtension {
prompt: "sounds/enter_extension.wav".to_string(),
prompt_text: None,
prompt_voice: None,
min_digits: 2,
max_digits: 4,
inter_digit_timeout_ms: 60,
},
},
MenuEntry {
key: "2".to_string(),
label: Some("Voicemail".to_string()),
action: EntryAction::Voicemail {
target: "1001".to_string(),
},
},
MenuEntry {
key: "3".to_string(),
label: Some("Queue".to_string()),
action: EntryAction::Queue {
target: "sales".to_string(),
return_to_ivr: None,
},
},
],
..Default::default()
},
menus: HashMap::new(),
}
}
#[tokio::test]
async fn test_ivr_collect_extension_transfer() {
let ivr = build_collect_extension_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(200, "PlayPrompt-collect", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.dtmf("2");
tokio::time::sleep(Duration::from_millis(10)).await;
stack.dtmf("0");
tokio::time::sleep(Duration::from_millis(10)).await;
stack.dtmf("1");
stack
.assert_cmd(
400,
"TransferTarget",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "201"),
)
.await;
stack
.join()
.await
.expect("should exit after extension transfer");
}
#[tokio::test]
async fn test_ivr_collect_extension_terminator() {
let ivr = build_collect_extension_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(200, "PlayPrompt-collect", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.dtmf("5").dtmf("5").dtmf("#");
stack
.assert_cmd(
400,
"TransferTarget",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "55"),
)
.await;
stack
.join()
.await
.expect("should exit after extension transfer via terminator");
}
#[tokio::test]
async fn test_ivr_voicemail_action_sends_transfer_target() {
let ivr = build_collect_extension_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("2");
stack
.assert_cmd(
300,
"TransferTarget-voicemail",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "voicemail:1001"),
)
.await;
stack
.join()
.await
.expect("should exit after voicemail transfer");
}
#[tokio::test]
async fn test_ivr_queue_action_sends_transfer_target() {
let ivr = build_collect_extension_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("3");
stack
.assert_cmd(
300,
"TransferTarget-queue",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "queue:sales"),
)
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
fn build_queue_return_to_ivr_ivr() -> IvrDefinition {
IvrDefinition {
name: "test-queue-return".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: "sounds/queue_menu.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![
MenuEntry {
key: "1".to_string(),
label: Some("Queue no return".to_string()),
action: EntryAction::Queue {
target: "normal_queue".to_string(),
return_to_ivr: None,
},
},
MenuEntry {
key: "2".to_string(),
label: Some("Queue with return".to_string()),
action: EntryAction::Queue {
target: "support".to_string(),
return_to_ivr: Some(true),
},
},
MenuEntry {
key: "3".to_string(),
label: Some("Queue return=false".to_string()),
action: EntryAction::Queue {
target: "overflow".to_string(),
return_to_ivr: Some(false),
},
},
],
..Default::default()
},
menus: HashMap::new(),
}
}
#[tokio::test]
async fn test_ivr_queue_action_default_no_return_to_ivr() {
let ivr = build_queue_return_to_ivr_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(
300,
"TransferTarget-queue-no-return",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "queue:normal_queue"),
)
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
#[tokio::test]
async fn test_ivr_queue_action_with_return_to_ivr() {
let ivr = build_queue_return_to_ivr_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("2");
stack
.assert_cmd(300, "TransferTarget-queue-with-return", |c| {
matches!(c, CallCommand::Transfer { target, .. }
if target == "queue:support?return_ivr=test-queue-return")
})
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
#[tokio::test]
async fn test_ivr_queue_action_return_to_ivr_false() {
let ivr = build_queue_return_to_ivr_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("3");
stack
.assert_cmd(
300,
"TransferTarget-queue-return-false",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "queue:overflow"),
)
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
async fn spawn_webhook_server(body: serde_json::Value) -> String {
use axum::{Json, Router, routing::any};
let port = portpicker::pick_unused_port().expect("no free port");
let router = Router::new().route(
"/hook",
any(move || {
let body = body.clone();
async move { Json(body) }
}),
);
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.expect("bind");
tokio::spawn(async move {
axum::serve(listener, router).await.ok();
});
format!("http://127.0.0.1:{}/hook", port)
}
fn build_webhook_ivr(url: impl Into<String>, method: Option<&str>) -> IvrDefinition {
IvrDefinition {
name: "webhook-ivr".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: "sounds/welcome.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Webhook".to_string()),
action: EntryAction::Webhook {
url: url.into(),
method: method.map(|s| s.to_string()),
headers: HashMap::new(),
variables: None,
timeout: 10,
},
}],
..Default::default()
},
menus: HashMap::new(),
}
}
#[tokio::test]
async fn test_ivr_webhook_transfer() {
let url = spawn_webhook_server(serde_json::json!({
"action": "transfer",
"params": { "target": "9001" }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(
1000,
"TransferTarget-9001",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "9001"),
)
.await;
stack
.join()
.await
.expect("should exit after webhook transfer");
}
#[tokio::test]
async fn test_ivr_webhook_hangup() {
let url = spawn_webhook_server(serde_json::json!({
"action": "hangup",
"params": { "prompt": null }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(1000, "Hangup-webhook", |c| {
matches!(c, CallCommand::Hangup(_))
})
.await;
}
#[tokio::test]
async fn test_ivr_webhook_hangup_with_prompt() {
let url = spawn_webhook_server(serde_json::json!({
"action": "hangup",
"params": { "prompt": "sounds/goodbye.wav" }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-goodbye", |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_ivr_webhook_play() {
let url = spawn_webhook_server(serde_json::json!({
"action": "play",
"params": { "prompt": "sounds/info.wav" }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-info", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "PlayPrompt-root-again", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_webhook_menu() {
use crate::call::app::ivr_config::MenuNode;
let url = spawn_webhook_server(serde_json::json!({
"action": "menu",
"params": { "menu": "support" }
}))
.await;
let mut ivr = build_webhook_ivr(&url, None);
ivr.menus.insert(
"support".to_string(),
MenuNode {
greeting: "sounds/support.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Billing".to_string()),
action: EntryAction::Transfer {
target: "3001".to_string(),
},
}],
..Default::default()
},
);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-root", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-support", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.join()
.await
.expect("should exit after billing transfer");
}
#[tokio::test]
async fn test_ivr_webhook_repeat() {
let url = spawn_webhook_server(serde_json::json!({ "action": "repeat" })).await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-repeat", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_webhook_queue() {
let url = spawn_webhook_server(serde_json::json!({
"action": "queue",
"params": { "target": "sales" }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(
1000,
"TransferTarget-queue",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "queue:sales"),
)
.await;
stack
.join()
.await
.expect("should exit after queue transfer");
}
#[tokio::test]
async fn test_ivr_webhook_voicemail() {
let url = spawn_webhook_server(serde_json::json!({
"action": "voicemail",
"params": { "target": "2001" }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(
1000,
"TransferTarget-voicemail",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "voicemail:2001"),
)
.await;
stack
.join()
.await
.expect("should exit after voicemail transfer");
}
#[tokio::test]
async fn test_ivr_webhook_collect_extension() {
let url = spawn_webhook_server(serde_json::json!({
"action": "collect_extension",
"params": {
"prompt": "sounds/enter_ext.wav",
"min_digits": 2,
"max_digits": 4,
"inter_digit_timeout_ms": 100
}
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-collect", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
tokio::time::sleep(Duration::from_millis(20)).await;
stack.dtmf("4");
tokio::time::sleep(Duration::from_millis(20)).await;
stack.dtmf("2");
tokio::time::sleep(Duration::from_millis(20)).await;
stack.dtmf("#");
stack
.assert_cmd(
400,
"TransferTarget-ext",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "42"),
)
.await;
stack
.join()
.await
.expect("should exit after collect transfer");
}
#[tokio::test]
async fn test_ivr_webhook_get_method() {
let url = spawn_webhook_server(serde_json::json!({
"action": "transfer",
"params": { "target": "7777" }
}))
.await;
let ivr = build_webhook_ivr(&url, Some("GET"));
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(
1000,
"TransferTarget-7777",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "7777"),
)
.await;
stack
.join()
.await
.expect("should exit after GET webhook transfer");
}
#[tokio::test]
async fn test_ivr_webhook_error_fallback() {
let port = portpicker::pick_unused_port().expect("no free port");
let url = format!("http://127.0.0.1:{}/hook", port);
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(2000, "PlayPrompt-fallback-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.cancel();
let _ = stack.join().await;
}
#[tokio::test]
async fn test_ivr_remote_hangup_during_collect_extension() {
let ivr = build_collect_extension_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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");
stack.dtmf("1");
stack
.assert_cmd(200, "PlayPrompt-collect", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
tokio::time::sleep(Duration::from_millis(10)).await;
stack.remote_hangup();
let result = stack.join().await;
assert!(
result.is_err(),
"hangup during collect_extension should propagate as error"
);
}
fn build_play_and_hangup_ivr() -> IvrDefinition {
IvrDefinition {
name: "play-and-hangup-ivr".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: "sounds/welcome.wav".to_string(),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![
MenuEntry {
key: "4".to_string(),
label: Some("Busy".to_string()),
action: EntryAction::PlayAndHangup {
prompt: Some("sounds/busy.wav".to_string()),
prompt_text: None,
prompt_voice: None,
code: Some(486),
},
},
MenuEntry {
key: "5".to_string(),
label: Some("Service Unavailable".to_string()),
action: EntryAction::PlayAndHangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
code: Some(503),
},
},
MenuEntry {
key: "6".to_string(),
label: Some("Goodbye no code".to_string()),
action: EntryAction::PlayAndHangup {
prompt: Some("sounds/goodbye.wav".to_string()),
prompt_text: None,
prompt_voice: None,
code: None,
},
},
],
..Default::default()
},
menus: HashMap::new(),
}
}
#[tokio::test]
async fn test_ivr_play_and_hangup_with_code() {
let ivr = build_play_and_hangup_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("4");
stack
.assert_cmd(200, "PlayPrompt-busy", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup-486", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_ivr_play_and_hangup_no_prompt() {
let ivr = build_play_and_hangup_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("5");
stack
.assert_cmd(200, "Hangup-503", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_ivr_play_and_hangup_without_code() {
let ivr = build_play_and_hangup_ivr();
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("6");
stack
.assert_cmd(200, "PlayPrompt-goodbye", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup-no-code", |c| {
matches!(c, CallCommand::Hangup(_))
})
.await;
}
fn create_e2e_wav(name: &str, num_samples: usize) -> String {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join(name);
let spec = hound::WavSpec {
channels: 1,
sample_rate: 8000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(path.to_str().unwrap(), spec).expect("WavWriter");
for i in 0..num_samples {
let sample = ((i as f32 / 8.0).sin() * 1000.0) as i16;
writer.write_sample(sample).expect("write_sample");
}
writer.finalize().expect("finalize");
path.to_string_lossy().to_string()
}
async fn wire_real_playback(
audio_file: &str,
stack: &MockCallStack,
) -> crate::media::FileTrack {
use crate::call::app::ControllerEvent;
use crate::media::FileTrack;
use audio_codec::CodecType;
let track = FileTrack::new("e2e-track".to_string())
.with_path(audio_file.to_string())
.with_loop(false)
.with_codec_preference(vec![CodecType::PCMU]);
let _ = track.local_description().await;
track.start_playback().await.expect("start_playback");
let tx = stack.event_sender();
let t = track.clone();
crate::utils::spawn(async move {
t.wait_for_completion().await;
let _ = tx.send(ControllerEvent::AudioComplete {
track_id: "default".to_string(),
interrupted: false,
});
});
track
}
async fn expect_and_play(
stack: &mut MockCallStack,
expected_path: &str,
label: &str,
) -> crate::media::FileTrack {
let cmd = stack
.next_cmd(500)
.await
.unwrap_or_else(|| panic!("timed out waiting for PlayPrompt ({label})"));
let audio_file = match &cmd {
CallCommand::Play {
source: MediaSource::File { path },
..
} => {
assert_eq!(path, expected_path, "Play path mismatch ({label})");
path.clone()
}
other => panic!("Expected Play ({label}), got {other:?}"),
};
wire_real_playback(&audio_file, stack).await
}
#[tokio::test]
async fn test_ivr_real_file_playback_drives_audio_complete() {
use tokio::fs;
let greeting_path = create_e2e_wav("test_ivr_e2e_greeting.wav", 160);
let ivr = IvrDefinition {
name: "e2e-ivr".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_path.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Transfer".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _track = expect_and_play(&mut stack, &greeting_path, "root greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
stack.drain_cmds().is_empty(),
"IVR should be idle waiting for DTMF after real file completion"
);
stack.dtmf("1");
stack
.join()
.await
.expect("IVR should exit cleanly after transfer");
let _ = fs::remove_file(&greeting_path).await;
}
#[tokio::test]
async fn test_ivr_e2e_submenu_real_playback() {
use tokio::fs;
let root_wav = create_e2e_wav("test_ivr_e2e_root.wav", 160);
let sub_wav = create_e2e_wav("test_ivr_e2e_sub.wav", 320);
let ivr = IvrDefinition {
name: "e2e-submenu".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: root_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "2".to_string(),
label: Some("Support".to_string()),
action: EntryAction::Menu {
menu: "support".to_string(),
},
}],
..Default::default()
},
menus: {
let mut m = HashMap::new();
m.insert(
"support".to_string(),
MenuNode {
greeting: sub_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Billing".to_string()),
action: EntryAction::Transfer {
target: "3001".to_string(),
},
}],
..Default::default()
},
);
m
},
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &root_wav, "root greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("2");
let _t2 = expect_and_play(&mut stack, &sub_wav, "support greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("1");
stack
.join()
.await
.expect("IVR should exit cleanly after billing transfer");
let _ = fs::remove_file(&root_wav).await;
let _ = fs::remove_file(&sub_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_hangup_with_real_goodbye() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_hg_greeting.wav", 160);
let goodbye_wav = create_e2e_wav("test_ivr_e2e_hg_goodbye.wav", 160);
let ivr = IvrDefinition {
name: "e2e-hangup".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "0".to_string(),
label: Some("Hangup".to_string()),
action: EntryAction::Hangup {
prompt: Some(goodbye_wav.clone()),
prompt_text: None,
prompt_voice: None,
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &greeting_wav, "greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("0");
let _t2 = expect_and_play(&mut stack, &goodbye_wav, "goodbye").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack
.assert_cmd(500, "Hangup", |c| matches!(c, CallCommand::Hangup(_)))
.await;
let _ = fs::remove_file(&greeting_wav).await;
let _ = fs::remove_file(&goodbye_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_invalid_key_retry_real_playback() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_inv_greeting.wav", 160);
let invalid_wav = create_e2e_wav("test_ivr_e2e_inv_invalid.wav", 160);
let ivr = IvrDefinition {
name: "e2e-invalid".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 2000,
max_retries: 2,
invalid_prompt: Some(invalid_wav.clone()),
timeout_action: Some(EntryAction::Repeat),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &greeting_wav, "greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("7");
let _t2 = expect_and_play(&mut stack, &invalid_wav, "invalid prompt").await;
tokio::time::sleep(Duration::from_millis(200)).await;
let _t3 = expect_and_play(&mut stack, &greeting_wav, "greeting replay").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("1");
stack.join().await.expect("IVR should exit after transfer");
let _ = fs::remove_file(&greeting_wav).await;
let _ = fs::remove_file(&invalid_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_play_returns_to_menu_real() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_play_greeting.wav", 160);
let announce_wav = create_e2e_wav("test_ivr_e2e_play_announce.wav", 320);
let ivr = IvrDefinition {
name: "e2e-play".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![
MenuEntry {
key: "3".to_string(),
label: Some("Info".to_string()),
action: EntryAction::Play {
prompt: announce_wav.clone(),
prompt_text: None,
prompt_voice: None,
},
},
MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
},
],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &greeting_wav, "greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("3");
let _t2 = expect_and_play(&mut stack, &announce_wav, "announcement").await;
tokio::time::sleep(Duration::from_millis(200)).await;
let _t3 = expect_and_play(&mut stack, &greeting_wav, "greeting replay").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("1");
stack.join().await.expect("IVR should exit after transfer");
let _ = fs::remove_file(&greeting_wav).await;
let _ = fs::remove_file(&announce_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_dtmf_bargein_during_real_playback() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_bargein.wav", 8000);
let ivr = IvrDefinition {
name: "e2e-bargein".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "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.dtmf("1");
stack
.join()
.await
.expect("IVR should exit after barge-in transfer");
let _ = fs::remove_file(&greeting_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_timeout_max_retries_real() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_timeout.wav", 160);
let ivr = IvrDefinition {
name: "e2e-timeout".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 150, max_retries: 2,
invalid_prompt: None,
timeout_action: Some(EntryAction::Repeat),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &greeting_wav, "greeting 1").await;
tokio::time::sleep(Duration::from_millis(200)).await;
let _t2 = expect_and_play(&mut stack, &greeting_wav, "greeting 2 (retry 1)").await;
tokio::time::sleep(Duration::from_millis(200)).await;
let _t3 = expect_and_play(&mut stack, &greeting_wav, "greeting 3 (retry 2)").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack
.assert_cmd(500, "Hangup-max-retries", |c| {
matches!(c, CallCommand::Hangup(_))
})
.await;
let _ = fs::remove_file(&greeting_wav).await;
}
#[tokio::test]
async fn test_ivr_e2e_play_and_hangup_with_code_real() {
use tokio::fs;
let greeting_wav = create_e2e_wav("test_ivr_e2e_pah_greeting.wav", 160);
let busy_wav = create_e2e_wav("test_ivr_e2e_pah_busy.wav", 160);
let ivr = IvrDefinition {
name: "e2e-play-and-hangup".to_string(),
description: None,
lang: None,
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: None,
root: MenuNode {
greeting: greeting_wav.clone(),
timeout_ms: 2000,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "4".to_string(),
label: Some("Busy".to_string()),
action: EntryAction::PlayAndHangup {
prompt: Some(busy_wav.clone()),
prompt_text: None,
prompt_voice: None,
code: Some(486),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
let _t1 = expect_and_play(&mut stack, &greeting_wav, "greeting").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack.dtmf("4");
let _t2 = expect_and_play(&mut stack, &busy_wav, "busy prompt").await;
tokio::time::sleep(Duration::from_millis(200)).await;
stack
.assert_cmd(500, "Hangup-486", |c| matches!(c, CallCommand::Hangup(_)))
.await;
let _ = fs::remove_file(&greeting_wav).await;
let _ = fs::remove_file(&busy_wav).await;
}
#[tokio::test]
async fn test_ivr_webhook_play_and_hangup() {
let url = spawn_webhook_server(serde_json::json!({
"action": "play_and_hangup",
"params": { "prompt": "sounds/busy.wav", "code": 486 }
}))
.await;
let ivr = build_webhook_ivr(&url, None);
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(200, "PlayPrompt-greeting", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(1000, "PlayPrompt-busy", |c| {
matches!(c, CallCommand::Play { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "Hangup-486", |c| matches!(c, CallCommand::Hangup(_)))
.await;
}
#[tokio::test]
async fn test_ivr_with_tts_greeting() {
use crate::tts::{BodyFormat, HttpTtsConfig, TtsConfig, TtsDriverConfig};
use axum::{Router, routing::get};
use std::collections::HashMap;
let wav = {
let mut tmp = tempfile::NamedTempFile::with_suffix(".wav").unwrap();
{
let spec = hound::WavSpec {
channels: 1,
sample_rate: 8000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer =
hound::WavWriter::new(std::io::BufWriter::new(tmp.as_file_mut()), spec)
.unwrap();
for _ in 0..800 {
writer.write_sample(0i16).unwrap();
}
writer.finalize().unwrap();
}
std::fs::read(tmp.path()).unwrap()
};
let wav_clone = wav.clone();
let app = Router::new().route(
"/tts",
get(
move |axum::extract::Query(params): axum::extract::Query<
HashMap<String, String>,
>| {
let wav = wav_clone.clone();
async move {
assert_eq!(params.get("text"), Some(&"hello from tts".to_string()));
assert_eq!(params.get("voice"), Some(&"xiaoxiao".to_string()));
([("content-type", "audio/wav")], wav)
}
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
let cache_dir = tempfile::tempdir().unwrap();
let tts_config = TtsConfig {
cache_dir: cache_dir.path().to_string_lossy().to_string(),
cache_ttl_seconds: 3600,
driver: TtsDriverConfig::Http(HttpTtsConfig {
url: format!("http://127.0.0.1:{}/tts", port),
method: "GET".to_string(),
param_name: "text".to_string(),
extra_params: {
let mut m = HashMap::new();
m.insert("voice".to_string(), "xiaoxiao".to_string());
m
},
headers: HashMap::new(),
output_format: "wav".to_string(),
timeout_seconds: 5,
body_format: BodyFormat::Query,
}),
};
let ivr = IvrDefinition {
name: "tts-ivr".to_string(),
description: None,
lang: Some("en".to_string()),
default_voice: None,
dynamic_build: false,
business_hours: None,
tts: Some(tts_config),
root: MenuNode {
greeting: "".to_string(),
greeting_text: Some("hello from tts".to_string()),
greeting_voice: Some("xiaoxiao".to_string()),
timeout_ms: 200,
max_retries: 1,
invalid_prompt: None,
timeout_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
max_retries_action: Some(EntryAction::Hangup {
prompt: None,
prompt_text: None,
prompt_voice: None,
}),
entries: vec![MenuEntry {
key: "1".to_string(),
label: Some("Sales".to_string()),
action: EntryAction::Transfer {
target: "2001".to_string(),
},
}],
..Default::default()
},
menus: HashMap::new(),
};
let mut stack = MockCallStack::run(Box::new(IvrApp::new(ivr)), "caller", "1000");
stack
.assert_cmd(200, "AcceptCall", |c| {
matches!(c, CallCommand::Answer { .. })
})
.await;
stack
.assert_cmd(500, "PlayPrompt-tts-greeting", |c| {
matches!(
c,
CallCommand::Play { source: MediaSource::File { path }, .. }
if path.contains(cache_dir.path().to_str().unwrap()) && path.ends_with(".wav")
)
})
.await;
stack.audio_complete("default");
stack.dtmf("1");
stack
.assert_cmd(
200,
"Transfer",
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "2001"),
)
.await;
}
}