#[cfg(test)]
mod tests {
use sea_orm::{Database, DatabaseConnection};
use sea_orm_migration::MigratorTrait;
use crate::addons::voicemail::mailbox_service::MailboxService;
use crate::addons::voicemail::migration::Migrator;
use crate::addons::voicemail::notifier::{
EmailNotifier, NewMessageEvent, Notifier, NotifierChain, SmtpConfig,
};
async fn setup_db() -> DatabaseConnection {
let db = Database::connect("sqlite::memory:")
.await
.expect("SQLite in-memory DB failed");
Migrator::up(&db, None)
.await
.expect("voicemail migrations failed");
db
}
fn svc(db: DatabaseConnection) -> MailboxService {
MailboxService::new(db)
}
#[tokio::test]
async fn test_create_mailbox_verify_pin() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("1001", Some("alice@example.com"), "9182")
.await
.expect("create_mailbox failed");
assert!(
svc.verify_pin("1001", "9182").await.unwrap(),
"correct PIN should verify"
);
assert!(
!svc.verify_pin("1001", "0000").await.unwrap(),
"wrong PIN should fail"
);
}
#[tokio::test]
async fn test_create_mailbox_duplicate_extension() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("1002", None, "1357").await.unwrap();
let result = svc.create_mailbox("1002", None, "2468").await;
assert!(result.is_err(), "duplicate extension should fail");
}
#[tokio::test]
async fn test_get_or_create_idempotent() {
let db = setup_db().await;
let svc = svc(db);
let (mb1, pin1) = svc.get_or_create("1003").await.unwrap();
assert!(pin1.is_some(), "first call should return a generated PIN");
let (mb2, pin2) = svc.get_or_create("1003").await.unwrap();
assert!(
pin2.is_none(),
"second call should NOT return a generated PIN"
);
assert_eq!(mb1.id, mb2.id, "mailbox UUID must be stable");
}
#[tokio::test]
async fn test_set_pin_self_service() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("1004", None, "9182").await.unwrap();
svc.set_pin("1004", Some("9182"), "1357")
.await
.expect("set_pin with correct old PIN failed");
assert!(svc.verify_pin("1004", "1357").await.unwrap());
assert!(!svc.verify_pin("1004", "9182").await.unwrap());
let bad = svc.set_pin("1004", Some("5678"), "3746").await;
assert!(bad.is_err(), "wrong old PIN should be rejected");
}
#[tokio::test]
async fn test_reset_pin_admin() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("1005", None, "9182").await.unwrap();
let new_pin = svc.reset_pin("1005").await.expect("reset_pin failed");
assert_eq!(new_pin.len(), 6, "generated PIN must be 6 digits");
assert!(
new_pin.chars().all(|c| c.is_ascii_digit()),
"generated PIN must be digits"
);
assert!(svc.verify_pin("1005", &new_pin).await.unwrap());
assert!(!svc.verify_pin("1005", "9182").await.unwrap());
}
#[tokio::test]
async fn test_set_email() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("1006", None, "2468").await.unwrap();
svc.set_email("1006", Some("bob@example.com"))
.await
.expect("set_email failed");
let mb = svc
.find_by_extension("1006")
.await
.expect("find failed")
.expect("mailbox not found");
assert_eq!(mb.email.as_deref(), Some("bob@example.com"));
}
#[tokio::test]
async fn test_chain_empty_is_noop() {
let chain = NotifierChain::new(vec![]);
let ev = sample_event();
chain.notify(&ev).await.unwrap();
}
#[tokio::test]
async fn test_email_notifier_no_email() {
let notifier = EmailNotifier::new(SmtpConfig::default());
let mut ev = sample_event();
ev.email = None;
notifier.notify(&ev).await.expect("should be no-op");
}
#[tokio::test]
async fn test_chain_absorbs_error() {
struct AlwaysError;
#[async_trait::async_trait]
impl Notifier for AlwaysError {
async fn notify(&self, _: &NewMessageEvent) -> anyhow::Result<()> {
Err(anyhow::anyhow!("simulated error"))
}
}
let chain = NotifierChain::new(vec![Box::new(AlwaysError)]);
let ev = sample_event();
chain.notify(&ev).await.unwrap();
}
#[test]
fn test_email_body_fields() {
let notifier = EmailNotifier::new(SmtpConfig::default());
let event = NewMessageEvent {
extension: "1001".into(),
email: Some("test@example.com".into()),
caller_id: "555-1234".into(),
duration_secs: 15,
transcript: Some("hi there".into()),
audio_url: Some("https://pbx/vm/001.wav".into()),
};
let body = notifier.build_body(&event);
assert!(body.contains("extension 1001"), "missing extension");
assert!(body.contains("555-1234"), "missing caller_id");
assert!(body.contains("15 second"), "missing duration");
assert!(body.contains("hi there"), "missing transcript");
assert!(body.contains("https://pbx/vm/001.wav"), "missing audio url");
}
#[test]
fn test_subject_template() {
let smtp = SmtpConfig {
subject_template: "VM: {caller} for {ext}".into(),
..Default::default()
};
let n = EmailNotifier::new(smtp);
let ev = NewMessageEvent {
extension: "2002".into(),
email: None,
caller_id: "13900139000".into(),
duration_secs: 5,
transcript: None,
audio_url: None,
};
assert_eq!(n.build_subject(&ev), "VM: 13900139000 for 2002");
}
fn sample_event() -> NewMessageEvent {
NewMessageEvent {
extension: "9000".into(),
email: Some("test@example.com".into()),
caller_id: "00000000000".into(),
duration_secs: 30,
transcript: None,
audio_url: None,
}
}
#[test]
fn test_file_config_default_round_trip() {
use crate::addons::voicemail::settings::FileConfig;
let original = FileConfig::default();
let toml_str = toml::to_string_pretty(&original).expect("serialize");
let back: FileConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(back.voicemail.max_duration_secs, 300);
assert_eq!(back.voicemail.silence_timeout_secs, 3);
assert!(back.smtp.is_none());
assert!(back.webhook_url.is_none());
}
#[test]
fn test_file_config_full_round_trip() {
use crate::addons::voicemail::notifier::SmtpConfig;
use crate::addons::voicemail::settings::FileConfig;
let mut cfg = FileConfig::default();
cfg.voicemail.max_duration_secs = 60;
cfg.voicemail.silence_timeout_secs = 5;
cfg.webhook_url = Some("https://hooks.example.com/vm".into());
cfg.smtp = Some(SmtpConfig {
host: "smtp.example.com".into(),
port: 465,
username: Some("bot@example.com".into()),
password: Some("secret123".into()),
from_addr: "vm@example.com".into(),
subject_template: "[VM] {caller} → {ext}".into(),
timeout_secs: 15,
});
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
let back: FileConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(back.voicemail.max_duration_secs, 60);
let smtp = back.smtp.expect("smtp must be present");
assert_eq!(smtp.host, "smtp.example.com");
assert_eq!(smtp.port, 465);
assert_eq!(smtp.password.as_deref(), Some("secret123"));
assert_eq!(
back.webhook_url.as_deref(),
Some("https://hooks.example.com/vm")
);
}
#[test]
fn test_file_config_partial_toml() {
use crate::addons::voicemail::settings::FileConfig;
let toml_str = r#"webhook_url = "https://example.com/hook""#;
let cfg: FileConfig = toml::from_str(toml_str).expect("deserialize");
assert_eq!(cfg.voicemail.max_duration_secs, 300);
assert_eq!(cfg.webhook_url.as_deref(), Some("https://example.com/hook"));
assert!(cfg.smtp.is_none());
}
#[test]
fn test_file_config_load_missing() {
use crate::addons::voicemail::settings::FileConfig;
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let cfg = FileConfig::load(&dir.path().join("nonexistent.toml"));
assert_eq!(cfg.voicemail.max_duration_secs, 300);
}
#[test]
fn test_file_config_save_and_load() {
use crate::addons::voicemail::settings::FileConfig;
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("sub").join("voicemail.toml");
let mut cfg = FileConfig::default();
cfg.voicemail.max_duration_secs = 45;
cfg.webhook_url = Some("https://example.com".into());
cfg.save(&path).expect("save");
let loaded = FileConfig::load(&path);
assert_eq!(loaded.voicemail.max_duration_secs, 45);
assert_eq!(loaded.webhook_url.as_deref(), Some("https://example.com"));
}
#[test]
fn test_smtp_enabled() {
use crate::addons::voicemail::notifier::SmtpConfig;
use crate::addons::voicemail::settings::FileConfig;
let mut cfg = FileConfig::default();
assert!(!cfg.smtp_enabled(), "no smtp → disabled");
cfg.smtp = Some(SmtpConfig {
host: "localhost".into(),
..SmtpConfig::default()
});
assert!(!cfg.smtp_enabled(), "localhost → disabled");
cfg.smtp = Some(SmtpConfig {
host: "smtp.sendgrid.net".into(),
..SmtpConfig::default()
});
assert!(cfg.smtp_enabled(), "remote host → enabled");
}
#[test]
fn test_settings_form_preserves_smtp_password() {
use crate::addons::voicemail::notifier::SmtpConfig;
use crate::addons::voicemail::settings::{FileConfig, SettingsForm};
let mut existing = FileConfig::default();
existing.smtp = Some(SmtpConfig {
host: "smtp.old.com".into(),
password: Some("do_not_erase".into()),
..SmtpConfig::default()
});
let form = SettingsForm {
spool_dir: "./spool".into(),
max_duration_secs: 300,
silence_timeout_secs: 3,
storage_type: "local".into(),
storage_path: Some("./recs".into()),
storage_bucket: None,
storage_region: None,
storage_access_key: None,
storage_secret_key: None,
storage_endpoint: None,
storage_prefix: None,
storage_vendor: None,
max_messages_per_mailbox: None,
max_age_days: None,
transcribe_enabled: None,
smtp_host: Some("smtp.new.com".into()),
smtp_port: Some(587),
smtp_username: None,
smtp_password: None, smtp_from_addr: None,
smtp_subject_template: None,
sounds_dir: None,
language: None,
webhook_url: None,
};
let updated = form.apply_to(existing);
let smtp = updated.smtp.expect("smtp must be set");
assert_eq!(smtp.host, "smtp.new.com");
assert_eq!(smtp.password.as_deref(), Some("do_not_erase")); }
#[test]
fn test_settings_form_clears_smtp() {
use crate::addons::voicemail::notifier::SmtpConfig;
use crate::addons::voicemail::settings::{FileConfig, SettingsForm};
let mut existing = FileConfig::default();
existing.smtp = Some(SmtpConfig::default());
let form = SettingsForm {
spool_dir: "./spool".into(),
max_duration_secs: 300,
silence_timeout_secs: 3,
storage_type: "local".into(),
storage_path: None,
storage_bucket: None,
storage_region: None,
storage_access_key: None,
storage_secret_key: None,
storage_endpoint: None,
storage_prefix: None,
storage_vendor: None,
max_messages_per_mailbox: None,
max_age_days: None,
transcribe_enabled: None,
smtp_host: Some("".into()), smtp_port: None,
smtp_username: None,
smtp_password: None,
smtp_from_addr: None,
smtp_subject_template: None,
sounds_dir: None,
language: None,
webhook_url: None,
};
let updated = form.apply_to(existing);
assert!(updated.smtp.is_none());
}
#[test]
fn test_greeting_key() {
use crate::addons::voicemail::storage::VoicemailStorage;
let key = VoicemailStorage::greeting_key("1001", "wav");
assert_eq!(key, "voicemail/1001/greeting.wav");
let key_mp3 = VoicemailStorage::greeting_key("2002", "mp3");
assert_eq!(key_mp3, "voicemail/2002/greeting.mp3");
}
#[tokio::test]
async fn test_greeting_upload_read_delete() {
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let spool = dir.path().join("spool");
let store = dir.path().join("store");
let cfg = VoicemailConfig {
spool_dir: spool.to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: store.to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
let storage = VoicemailStorage::from_config(&cfg).expect("build storage");
let audio = b"fake wav data";
let key = storage
.upload_greeting(audio, "1001", "wav")
.await
.expect("upload_greeting failed");
assert_eq!(key, "voicemail/1001/greeting.wav");
let back = storage
.get_greeting(&key)
.await
.expect("get_greeting failed");
assert_eq!(back.as_ref(), audio);
storage
.delete_greeting(&key)
.await
.expect("delete_greeting failed");
let result = storage.get_greeting(&key).await;
assert!(result.is_err(), "reading deleted greeting should fail");
}
#[tokio::test]
async fn test_spool_path_creates_dir() {
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let cfg = VoicemailConfig {
spool_dir: dir.path().join("spool").to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: dir.path().join("store").to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
let storage = VoicemailStorage::from_config(&cfg).expect("build storage");
let path = storage.spool_path("1001", 1_700_000_000);
assert!(
path.parent().unwrap().exists(),
"spool dir should be created"
);
assert!(path.ends_with("1700000000.wav"));
}
#[tokio::test]
async fn test_set_and_clear_greeting_path() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("2001", None, "9182").await.unwrap();
svc.set_greeting_path("2001", Some("voicemail/2001/greeting.wav"))
.await
.expect("set_greeting_path failed");
let mb = svc.find_by_extension("2001").await.unwrap().unwrap();
assert_eq!(
mb.greeting_path.as_deref(),
Some("voicemail/2001/greeting.wav")
);
svc.set_greeting_path("2001", None)
.await
.expect("clear greeting_path failed");
let mb2 = svc.find_by_extension("2001").await.unwrap().unwrap();
assert!(mb2.greeting_path.is_none());
}
#[tokio::test]
async fn test_insert_and_query_message() {
use crate::addons::voicemail::models::message::{
ActiveModel as MessageActiveModel, Column as MessageColumn, Entity as MessageEntity,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
let db = setup_db().await;
let svc = svc(db.clone());
let mb = svc.create_mailbox("3001", None, "9182").await.unwrap();
let msg_id = Uuid::new_v4();
let row = MessageActiveModel {
id: Set(msg_id),
box_id: Set(mb.id),
caller_id: Set("555-0100".to_string()),
duration: Set(15),
audio_path: Set("voicemail/3001/1700000000.wav".to_string()),
read: Set(false),
created_at: Set(chrono::Utc::now().naive_utc()),
transcript: Set(None),
summary: Set(None),
};
MessageEntity::insert(row)
.exec_without_returning(&db)
.await
.expect("insert message failed");
let msgs = MessageEntity::find()
.filter(MessageColumn::BoxId.eq(mb.id))
.all(&db)
.await
.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].caller_id, "555-0100");
assert_eq!(msgs[0].duration, 15);
assert!(!msgs[0].read);
}
#[tokio::test]
async fn test_mailbox_cascade_delete() {
use crate::addons::voicemail::models::{
mailbox::ActiveModel as MailboxActiveModel,
message::{
ActiveModel as MessageActiveModel, Column as MessageColumn, Entity as MessageEntity,
},
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
let db = setup_db().await;
let svc = svc(db.clone());
let mb = svc.create_mailbox("3002", None, "9182").await.unwrap();
let box_id = mb.id;
for i in 0..2 {
let row = MessageActiveModel {
id: Set(Uuid::new_v4()),
box_id: Set(box_id),
caller_id: Set(format!("caller-{}", i)),
duration: Set(10 + i),
audio_path: Set(format!("voicemail/3002/{}.wav", i)),
read: Set(false),
created_at: Set(chrono::Utc::now().naive_utc()),
transcript: Set(None),
summary: Set(None),
};
MessageEntity::insert(row)
.exec_without_returning(&db)
.await
.unwrap();
}
let count = MessageEntity::find()
.filter(MessageColumn::BoxId.eq(box_id))
.all(&db)
.await
.unwrap()
.len();
assert_eq!(count, 2);
let active: MailboxActiveModel = mb.into();
active.delete(&db).await.expect("delete mailbox failed");
let count_after = MessageEntity::find()
.filter(MessageColumn::BoxId.eq(box_id))
.all(&db)
.await
.unwrap()
.len();
assert_eq!(count_after, 0, "messages should be cascade-deleted");
}
#[tokio::test]
async fn test_mark_message_read() {
use crate::addons::voicemail::models::message::{
ActiveModel as MessageActiveModel, Entity as MessageEntity,
};
use sea_orm::{ActiveModelTrait, EntityTrait, Set};
use uuid::Uuid;
let db = setup_db().await;
let svc = svc(db.clone());
let mb = svc.create_mailbox("3003", None, "9182").await.unwrap();
let msg_id = Uuid::new_v4();
let row = MessageActiveModel {
id: Set(msg_id),
box_id: Set(mb.id),
caller_id: Set("555-0200".to_string()),
duration: Set(20),
audio_path: Set("voicemail/3003/1700000001.wav".to_string()),
read: Set(false),
created_at: Set(chrono::Utc::now().naive_utc()),
transcript: Set(None),
summary: Set(None),
};
MessageEntity::insert(row)
.exec_without_returning(&db)
.await
.unwrap();
let msg = MessageEntity::find_by_id(msg_id)
.one(&db)
.await
.unwrap()
.unwrap();
assert!(!msg.read);
let mut active: MessageActiveModel = msg.into();
active.read = Set(true);
active.update(&db).await.unwrap();
let msg2 = MessageEntity::find_by_id(msg_id)
.one(&db)
.await
.unwrap()
.unwrap();
assert!(msg2.read);
}
#[test]
fn test_webhook_notifier_builder() {
use crate::addons::voicemail::notifier::WebhookNotifier;
let wh = WebhookNotifier::new("https://example.com/hook")
.with_token("my-secret-token")
.with_timeout(30);
let _ = wh;
}
#[test]
fn test_chain_push() {
let mut chain = NotifierChain::new(vec![]);
assert!(chain.is_empty());
chain.push(Box::new(EmailNotifier::new(SmtpConfig::default())));
assert!(!chain.is_empty());
}
#[tokio::test]
async fn test_arc_notifier() {
use std::sync::Arc;
let chain = Arc::new(NotifierChain::new(vec![]));
let ev = sample_event();
Notifier::notify(&chain, &ev).await.unwrap();
}
#[tokio::test]
async fn test_mwi_notifier_disabled() {
use crate::addons::voicemail::notifier::{MwiConfig, MwiNotifier};
let notifier = MwiNotifier::new(MwiConfig::default());
let ev = sample_event();
notifier.notify(&ev).await.unwrap();
}
#[tokio::test]
async fn test_mwi_notifier_enabled() {
use crate::addons::voicemail::notifier::{MwiConfig, MwiNotifier};
let config = MwiConfig {
enabled: true,
..MwiConfig::default()
};
let notifier = MwiNotifier::new(config);
let ev = sample_event();
notifier.notify(&ev).await.unwrap();
}
#[tokio::test]
async fn test_mwi_service_disabled() {
use crate::addons::voicemail::notifier::{MwiConfig, MwiService};
let svc = MwiService::new(MwiConfig::default());
svc.notify_waiting("1001", 3, 5).await.unwrap();
}
#[tokio::test]
async fn test_mwi_service_enabled() {
use crate::addons::voicemail::notifier::{MwiConfig, MwiService};
let config = MwiConfig {
enabled: true,
..MwiConfig::default()
};
let svc = MwiService::new(config);
svc.notify_waiting("1001", 1, 0).await.unwrap();
svc.notify_waiting("1001", 0, 0).await.unwrap();
}
#[tokio::test]
async fn test_email_notifier_empty_email() {
let notifier = EmailNotifier::new(SmtpConfig::default());
let mut ev = sample_event();
ev.email = Some("".to_string());
notifier
.notify(&ev)
.await
.expect("should be no-op for empty email");
}
#[test]
fn test_email_body_singular_duration() {
let notifier = EmailNotifier::new(SmtpConfig::default());
let ev = NewMessageEvent {
extension: "5001".into(),
email: Some("test@example.com".into()),
caller_id: "555-0001".into(),
duration_secs: 1,
transcript: None,
audio_url: None,
};
let body = notifier.build_body(&ev);
assert!(
body.contains("1 second\r\n"),
"should use singular 'second'"
);
assert!(
!body.contains("seconds"),
"should not use plural for 1 second"
);
}
#[tokio::test]
async fn test_set_pin_reuse_rejected() {
let db = setup_db().await;
let svc = svc(db);
svc.create_mailbox("4001", None, "9182").await.unwrap();
let result = svc.set_pin("4001", Some("9182"), "9182").await;
assert!(result.is_err(), "reusing same PIN should fail");
assert!(
result.unwrap_err().to_string().contains("differ"),
"error should mention PIN must differ"
);
}
#[tokio::test]
async fn test_verify_pin_nonexistent() {
let db = setup_db().await;
let svc = svc(db);
let result = svc.verify_pin("9999", "1234").await;
assert!(
result.is_err(),
"verify_pin on missing extension should fail"
);
}
#[tokio::test]
async fn test_recording_key_format() {
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let spool = dir.path().join("spool");
let store = dir.path().join("store");
let cfg = VoicemailConfig {
spool_dir: spool.to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: store.to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
let storage = VoicemailStorage::from_config(&cfg).expect("build storage");
let spool_path = storage.spool_path("5001", 1700000000);
tokio::fs::write(&spool_path, b"fake wav data")
.await
.unwrap();
let key = storage
.upload_recording(&spool_path, "5001", 1700000000)
.await
.expect("upload_recording failed");
assert_eq!(key, "voicemail/5001/1700000000.wav");
}
#[tokio::test]
async fn test_discard_spool() {
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let cfg = VoicemailConfig {
spool_dir: dir.path().join("spool").to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: dir.path().join("store").to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
let storage = VoicemailStorage::from_config(&cfg).expect("build storage");
let spool_path = storage.spool_path("5002", 1700000001);
tokio::fs::write(&spool_path, b"discard me").await.unwrap();
assert!(spool_path.exists());
storage.discard_spool(&spool_path).await;
assert!(!spool_path.exists(), "spool file should be deleted");
}
#[test]
fn test_storage_is_local() {
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let cfg = VoicemailConfig {
spool_dir: dir.path().join("spool").to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: dir.path().join("store").to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
let storage = VoicemailStorage::from_config(&cfg).expect("build storage");
assert!(storage.is_local());
}
#[test]
fn test_voicemail_config_defaults() {
use crate::addons::voicemail::storage::VoicemailConfig;
let cfg = VoicemailConfig::default();
assert_eq!(cfg.max_duration_secs, 300);
assert_eq!(cfg.silence_timeout_secs, 3);
assert_eq!(cfg.spool_dir, "./config/voicemail/spool");
assert!(!cfg.transcribe_enabled);
assert!(cfg.max_messages_per_mailbox.is_none());
assert!(cfg.max_age_days.is_none());
}
#[test]
fn test_voicemail_config_round_trip() {
use crate::addons::voicemail::storage::{VoicemailConfig, VoicemailStorageConfig};
let cfg = VoicemailConfig {
spool_dir: "/tmp/spool".to_string(),
max_duration_secs: 120,
silence_timeout_secs: 5,
storage: VoicemailStorageConfig::Local {
path: "/tmp/store".to_string(),
},
max_messages_per_mailbox: Some(50),
max_age_days: Some(30),
transcribe_enabled: true,
..Default::default()
};
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
let back: VoicemailConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(back.max_duration_secs, 120);
assert_eq!(back.silence_timeout_secs, 5);
assert_eq!(back.max_messages_per_mailbox, Some(50));
assert_eq!(back.max_age_days, Some(30));
assert!(back.transcribe_enabled);
}
#[tokio::test]
async fn test_chain_calls_all_notifiers() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct CountingNotifier(Arc<AtomicU32>);
#[async_trait::async_trait]
impl Notifier for CountingNotifier {
async fn notify(&self, _: &NewMessageEvent) -> anyhow::Result<()> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct FailingNotifier;
#[async_trait::async_trait]
impl Notifier for FailingNotifier {
async fn notify(&self, _: &NewMessageEvent) -> anyhow::Result<()> {
Err(anyhow::anyhow!("boom"))
}
}
let counter = Arc::new(AtomicU32::new(0));
let chain = NotifierChain::new(vec![
Box::new(CountingNotifier(counter.clone())),
Box::new(FailingNotifier),
Box::new(CountingNotifier(counter.clone())),
]);
let ev = sample_event();
chain.notify(&ev).await.unwrap();
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"both counting notifiers should have been called"
);
}
mod app_ivr {
use crate::addons::voicemail::app::{VoicemailApp, VoicemailSounds};
use crate::addons::voicemail::notifier::NotifierChain;
use crate::addons::voicemail::storage::{
VoicemailConfig, VoicemailStorage, VoicemailStorageConfig,
};
use crate::call::app::testing::MockCallStack;
use crate::proxy::proxy_call::state::SessionAction;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use uuid::Uuid;
fn make_storage(dir: &TempDir) -> Arc<VoicemailStorage> {
let cfg = VoicemailConfig {
spool_dir: dir.path().join("spool").to_str().unwrap().to_string(),
storage: VoicemailStorageConfig::Local {
path: dir.path().join("store").to_str().unwrap().to_string(),
},
..VoicemailConfig::default()
};
Arc::new(VoicemailStorage::from_config(&cfg).expect("storage"))
}
fn sounds() -> VoicemailSounds {
VoicemailSounds {
greeting_default: "sounds/vm_greeting.wav".into(),
beep: "sounds/vm_beep.wav".into(),
saved: "sounds/vm_saved.wav".into(),
}
}
fn make_app(storage: Arc<VoicemailStorage>, db: DatabaseConnection) -> Box<VoicemailApp> {
Box::new(VoicemailApp::new(
"1001".into(),
Uuid::new_v4(),
"1000".into(),
None,
Duration::from_secs(60),
storage,
db,
None,
Arc::new(NotifierChain::new(vec![])),
sounds(),
))
}
#[tokio::test]
async fn test_voicemail_full_happy_path() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let mut stack = MockCallStack::run(
make_app(storage, DatabaseConnection::Disconnected),
"1000",
"1001",
);
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("greeting"))
}).await;
stack.audio_complete("default");
stack.assert_cmd(200, "beep", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("beep"))
}).await;
stack.audio_complete("default");
stack
.assert_cmd(200, "record", |c| {
matches!(c, SessionAction::StartRecording { .. })
})
.await;
stack.record_complete(
dir.path().join("spool/1001/1000000.wav").to_str().unwrap(),
Duration::from_secs(8),
64_000,
);
stack.assert_cmd(200, "saved", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("saved"))
}).await;
stack.audio_complete("default");
stack
.assert_cmd(200, "hangup", |c| matches!(c, SessionAction::Hangup { .. }))
.await;
}
#[tokio::test]
async fn test_voicemail_hash_stops_recording() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let mut stack = MockCallStack::run(
make_app(storage, DatabaseConnection::Disconnected),
"1000",
"1001",
);
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "beep", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "record-start", |c| {
matches!(c, SessionAction::StartRecording { .. })
})
.await;
stack.dtmf("#");
stack.record_complete(
dir.path().join("spool/1001/1000001.wav").to_str().unwrap(),
Duration::from_secs(5),
40_000,
);
stack.assert_cmd(200, "saved", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("saved"))
}).await;
stack.audio_complete("default");
stack
.assert_cmd(200, "hangup", |c| matches!(c, SessionAction::Hangup { .. }))
.await;
}
#[tokio::test]
async fn test_voicemail_zero_duration_recording_discarded() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let mut stack = MockCallStack::run(
make_app(storage, DatabaseConnection::Disconnected),
"1000",
"1001",
);
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "beep", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "record", |c| {
matches!(c, SessionAction::StartRecording { .. })
})
.await;
stack.record_complete("/tmp/none.wav", Duration::ZERO, 0);
stack
.assert_cmd(200, "saved", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "hangup", |c| matches!(c, SessionAction::Hangup { .. }))
.await;
}
#[tokio::test]
async fn test_voicemail_hangup_during_greeting() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let mut stack = MockCallStack::run(
make_app(storage, DatabaseConnection::Disconnected),
"1000",
"1001",
);
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.remote_hangup();
stack.cancel(); let any_extra = stack.next_cmd(50).await;
assert!(
any_extra.map_or(true, |c| !matches!(c, SessionAction::AcceptCall { .. })),
"unexpected extra command after remote hangup"
);
}
#[tokio::test]
async fn test_voicemail_hangup_during_recording() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let mut stack = MockCallStack::run(
make_app(storage, DatabaseConnection::Disconnected),
"1000",
"1001",
);
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "beep", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack
.assert_cmd(200, "record", |c| {
matches!(c, SessionAction::StartRecording { .. })
})
.await;
stack.remote_hangup();
let next = stack.next_cmd(80).await;
assert!(
next.as_ref()
.map_or(true, |c| !matches!(c, SessionAction::PlayPrompt { .. })),
"should not play 'saved' after remote hangup: {:?}",
next
);
}
#[tokio::test]
async fn test_voicemail_custom_greeting_path_used() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let store_dir = dir.path().join("store");
let custom_dir = store_dir.join("voicemail").join("1001");
std::fs::create_dir_all(&custom_dir).unwrap();
let custom_path = custom_dir.join("custom.wav");
std::fs::write(&custom_path, b"fake wav").unwrap();
let custom_key = "voicemail/1001/custom.wav".to_string();
let app = Box::new(VoicemailApp::new(
"1001".into(),
Uuid::new_v4(),
"1000".into(),
None,
Duration::from_secs(60),
storage,
DatabaseConnection::Disconnected,
Some(custom_key),
Arc::new(NotifierChain::new(vec![])),
sounds(),
));
let mut stack = MockCallStack::run(app, "1000", "1001");
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "custom-greeting", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. }
if audio_file.contains("custom.wav"))
})
.await;
}
#[tokio::test]
async fn test_voicemail_max_duration_forwarded() {
tokio::time::pause();
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let max = Duration::from_secs(30);
let app = Box::new(VoicemailApp::new(
"1001".into(),
Uuid::new_v4(),
"1000".into(),
None,
max,
Arc::clone(&storage),
DatabaseConnection::Disconnected,
None,
Arc::new(NotifierChain::new(vec![])),
sounds(),
));
let mut stack = MockCallStack::run(app, "1000", "1001");
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "greeting", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default"); stack
.assert_cmd(200, "beep", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.audio_complete("default");
stack.assert_cmd(200, "record-with-max", |c| {
matches!(c, SessionAction::StartRecording { max_duration: Some(d), .. } if *d == max)
}).await;
}
#[tokio::test]
async fn test_check_voicemail_no_messages() {
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let db = {
use crate::addons::voicemail::migration::Migrator;
use sea_orm::Database;
use sea_orm_migration::MigratorTrait;
let db = Database::connect("sqlite::memory:").await.unwrap();
Migrator::up(&db, None).await.unwrap();
db
};
use crate::addons::voicemail::mailbox_service::MailboxService;
let svc = MailboxService::new(db.clone());
svc.create_mailbox("1001", None, "7291").await.unwrap();
use crate::addons::voicemail::check_app::{CheckVoicemailApp, CheckVoicemailSounds};
let sounds = CheckVoicemailSounds {
menu: "sounds/vm_menu.wav".into(),
enter_extension: "sounds/vm_enter_ext.wav".into(),
enter_pin: "sounds/vm_enter_pin.wav".into(),
auth_failed: "sounds/vm_auth_failed.wav".into(),
wrong_pin: "sounds/vm_wrong_pin.wav".into(),
error: "sounds/vm_error.wav".into(),
no_messages: "sounds/vm_no_messages.wav".into(),
no_more_messages: "sounds/vm_no_more_messages.wav".into(),
saved: "sounds/vm_saved.wav".into(),
deleted: "sounds/vm_deleted.wav".into(),
};
let app = Box::new(CheckVoicemailApp::new(
MailboxService::new(db),
Arc::clone(&storage),
sounds,
));
let mut stack = MockCallStack::run(app, "1000", "*97");
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack.assert_cmd(200, "ext-prompt", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("enter_ext"))
}).await;
stack.dtmf("1001").dtmf("#");
stack.assert_cmd(200, "pin-prompt", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("enter_pin"))
}).await;
stack.dtmf("7291").dtmf("#");
stack.assert_cmd(5000, "no-messages", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("no_messages"))
}).await;
stack.audio_complete("default");
stack
.assert_cmd(200, "hangup", |c| matches!(c, SessionAction::Hangup { .. }))
.await;
}
#[tokio::test]
async fn test_check_voicemail_auth_lockout() {
let dir = TempDir::new().unwrap();
let storage = make_storage(&dir);
let db = {
use crate::addons::voicemail::migration::Migrator;
use sea_orm::Database;
use sea_orm_migration::MigratorTrait;
let db = Database::connect("sqlite::memory:").await.unwrap();
Migrator::up(&db, None).await.unwrap();
db
};
use crate::addons::voicemail::mailbox_service::MailboxService;
let svc = MailboxService::new(db.clone());
svc.create_mailbox("1002", None, "8374").await.unwrap();
use crate::addons::voicemail::check_app::{CheckVoicemailApp, CheckVoicemailSounds};
let sounds = CheckVoicemailSounds {
menu: "sounds/vm_menu.wav".into(),
enter_extension: "sounds/vm_enter_ext.wav".into(),
enter_pin: "sounds/vm_enter_pin.wav".into(),
auth_failed: "sounds/vm_auth_failed.wav".into(),
wrong_pin: "sounds/vm_wrong_pin.wav".into(),
error: "sounds/vm_error.wav".into(),
no_messages: "sounds/vm_no_messages.wav".into(),
no_more_messages: "sounds/vm_no_more_messages.wav".into(),
saved: "sounds/vm_saved.wav".into(),
deleted: "sounds/vm_deleted.wav".into(),
};
let app = Box::new(CheckVoicemailApp::new(
MailboxService::new(db),
Arc::clone(&storage),
sounds,
));
let mut stack = MockCallStack::run(app, "1000", "*97");
stack
.assert_cmd(200, "answer", |c| {
matches!(c, SessionAction::AcceptCall { .. })
})
.await;
stack
.assert_cmd(200, "ext-prompt", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.dtmf("1002").dtmf("#");
stack
.assert_cmd(200, "pin-prompt-1", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.dtmf("0000").dtmf("#");
stack.assert_cmd(500, "wrong-pin-1", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("wrong_pin"))
}).await;
stack
.assert_cmd(200, "pin-prompt-2", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.dtmf("1111").dtmf("#");
stack.assert_cmd(500, "wrong-pin-2", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("wrong_pin"))
}).await;
stack
.assert_cmd(200, "pin-prompt-3", |c| {
matches!(c, SessionAction::PlayPrompt { .. })
})
.await;
stack.dtmf("2222").dtmf("#");
stack.assert_cmd(500, "auth-failed", |c| {
matches!(c, SessionAction::PlayPrompt { audio_file, .. } if audio_file.contains("auth_failed"))
}).await;
stack
.assert_cmd(500, "hangup", |c| matches!(c, SessionAction::Hangup { .. }))
.await;
}
}
}