use crate::brain::tools::whatsapp_send::{
build_vcard, delivered_prefix, get_f64, get_str, mime_from_extension, partial_failure_report,
tag_with_header,
};
use serde_json::json;
#[test]
fn get_str_valid() {
let input = json!({"message": "hello"});
assert_eq!(get_str(&input, "message").unwrap(), "hello");
}
#[test]
fn get_str_missing() {
let input = json!({});
let err = get_str(&input, "message").unwrap_err();
assert!(!err.success);
assert!(err.error.unwrap().contains("Missing required parameter"));
}
#[test]
fn get_str_empty() {
let input = json!({"message": ""});
let err = get_str(&input, "message").unwrap_err();
assert!(!err.success);
}
#[test]
fn get_str_wrong_type() {
let input = json!({"message": 42});
let err = get_str(&input, "message").unwrap_err();
assert!(!err.success);
}
#[test]
fn get_f64_valid() {
let input = json!({"latitude": 40.7128});
assert_eq!(get_f64(&input, "latitude"), Some(40.7128));
}
#[test]
fn get_f64_integer_coerces() {
let input = json!({"latitude": 40});
assert_eq!(get_f64(&input, "latitude"), Some(40.0));
}
#[test]
fn get_f64_missing() {
let input = json!({});
assert_eq!(get_f64(&input, "latitude"), None);
}
#[test]
fn get_f64_wrong_type() {
let input = json!({"latitude": "not a number"});
assert_eq!(get_f64(&input, "latitude"), None);
}
#[test]
fn build_vcard_format() {
let vcard = build_vcard("John Doe", "+15551234567");
assert!(vcard.starts_with("BEGIN:VCARD"));
assert!(vcard.contains("FN:John Doe"));
assert!(vcard.contains("TEL;TYPE=CELL:+15551234567"));
assert!(vcard.ends_with("END:VCARD"));
assert!(vcard.contains("VERSION:3.0"));
}
#[test]
fn build_vcard_special_chars() {
let vcard = build_vcard("O'Brien", "+351933536442");
assert!(vcard.contains("FN:O'Brien"));
assert!(vcard.contains("TEL;TYPE=CELL:+351933536442"));
}
#[test]
fn mime_jpg() {
assert_eq!(mime_from_extension("photo.jpg").unwrap(), "image/jpeg");
assert_eq!(mime_from_extension("photo.jpeg").unwrap(), "image/jpeg");
}
#[test]
fn mime_png() {
assert_eq!(mime_from_extension("image.PNG").unwrap(), "image/png");
}
#[test]
fn mime_video() {
assert_eq!(mime_from_extension("clip.mp4").unwrap(), "video/mp4");
assert_eq!(mime_from_extension("clip.mov").unwrap(), "video/quicktime");
assert_eq!(mime_from_extension("clip.3gp").unwrap(), "video/3gpp");
}
#[test]
fn mime_audio() {
assert_eq!(mime_from_extension("voice.ogg").unwrap(), "audio/ogg");
assert_eq!(mime_from_extension("voice.opus").unwrap(), "audio/ogg");
assert_eq!(mime_from_extension("song.mp3").unwrap(), "audio/mpeg");
assert_eq!(mime_from_extension("track.m4a").unwrap(), "audio/mp4");
assert_eq!(mime_from_extension("clip.aac").unwrap(), "audio/aac");
}
#[test]
fn mime_document() {
assert_eq!(mime_from_extension("doc.pdf").unwrap(), "application/pdf");
assert_eq!(mime_from_extension("file.txt").unwrap(), "text/plain");
assert_eq!(mime_from_extension("data.csv").unwrap(), "text/csv");
assert_eq!(
mime_from_extension("archive.zip").unwrap(),
"application/zip"
);
}
#[test]
fn mime_sticker() {
assert_eq!(mime_from_extension("sticker.webp").unwrap(), "image/webp");
}
#[test]
fn mime_unknown_returns_none() {
assert!(mime_from_extension("file.xyz").is_none());
assert!(mime_from_extension("noext").is_none());
}
#[test]
fn schema_has_all_14_actions() {
let expected_actions = [
"send",
"reply",
"delete",
"send_photo",
"send_document",
"send_audio",
"send_video",
"send_sticker",
"send_location",
"send_contact",
"react",
"send_poll",
"typing",
"mark_read",
];
assert_eq!(expected_actions.len(), 14, "expected 14 actions");
let schema_actions = [
"send",
"reply",
"delete",
"send_photo",
"send_document",
"send_audio",
"send_video",
"send_sticker",
"send_location",
"send_contact",
"react",
"send_poll",
"typing",
"mark_read",
];
for action in &schema_actions {
assert!(
expected_actions.contains(action),
"schema action '{}' not in expected list",
action
);
}
}
#[tokio::test]
async fn unknown_action_returns_error() {
let unknown = "nonexistent_action";
let valid_actions = "send, reply, delete, send_photo, send_document, \
send_audio, send_video, send_sticker, send_location, send_contact, \
react, send_poll, typing, mark_read";
let error_msg = format!(
"Unknown action '{}'. Valid actions: {}",
unknown, valid_actions
);
assert!(error_msg.contains("nonexistent_action"));
assert!(error_msg.contains("send_poll"));
assert!(error_msg.contains("mark_read"));
}
#[test]
fn tag_with_header_prepends_attribution() {
let tagged = tag_with_header("hello world");
assert!(
tagged.starts_with(crate::channels::whatsapp::handler::MSG_HEADER),
"tagged text must start with the attribution header, got: {tagged:?}"
);
assert!(
tagged.ends_with("\n\nhello world"),
"header and body must be separated by a blank line, got: {tagged:?}"
);
}
#[test]
fn every_persist_call_uses_the_tagged_form() {
const SRC: &str = include_str!("../brain/tools/whatsapp_send.rs");
let call_sites: Vec<&str> = SRC
.lines()
.map(str::trim)
.filter(|l| l.contains("persist_outgoing(") && !l.contains("fn persist_outgoing"))
.collect();
assert!(
call_sites.len() >= 2,
"expected send + reply persist calls, got {call_sites:?}"
);
for site in &call_sites {
assert!(
site.contains("&tagged") || site.contains("&prefix"),
"persist call not using the tagged form: {site}"
);
}
}
#[test]
fn split_message_chunks_reconcatenate_to_original() {
let text = "word ".repeat(2000); let chunks = crate::channels::whatsapp::handler::split_message(&text, 4000);
assert!(chunks.len() > 1, "test text must exceed the chunk limit");
assert_eq!(
chunks.concat(),
text.as_str(),
"chunks must tile the original text exactly"
);
}
#[test]
fn delivered_prefix_concats_the_delivered_chunks() {
assert_eq!(delivered_prefix(&[]), "");
assert_eq!(
delivered_prefix(&["alpha ", "beta"]),
"alpha beta".to_string()
);
}
#[test]
fn partial_failure_report_names_delivery_state() {
let none = partial_failure_report(3, 0, "net down");
assert!(none.contains("net down"));
assert!(none.contains("No chunks were delivered"));
assert!(none.contains("safe to retry the whole message"));
let partial = partial_failure_report(3, 2, "net down");
assert!(partial.contains("chunk 3 of 3 failed"));
assert!(partial.contains("net down"));
assert!(partial.contains("Chunks 1-2 were DELIVERED and persisted"));
assert!(partial.contains("do NOT resend"));
assert!(partial.contains("remaining text"));
}
#[test]
fn send_arm_accounts_for_partial_delivery() {
const SRC: &str = include_str!("../brain/tools/whatsapp_send.rs");
let (_, rest) = SRC.split_once("\"send\" =>").expect("send arm not found");
let (arm, _) = rest.split_once("// ── reply").expect("reply arm not found");
assert!(
arm.contains("let mut delivered: Vec<&str>"),
"no delivery tracking"
);
assert!(
arm.contains("delivered.push(chunk)"),
"loop does not record successes"
);
assert!(
arm.contains("delivered_prefix(&delivered)"),
"failure branch does not compute the delivered prefix"
);
assert!(
arm.contains("persist_outgoing(&jid, &prefix)"),
"delivered prefix not persisted on failure"
);
assert!(
arm.contains("partial_failure_report("),
"failure branch does not use the accounting report"
);
}
#[test]
fn tagged_reply_lead_chunk_carries_the_header() {
let tagged = tag_with_header(&"x".repeat(9000));
let chunks = crate::channels::whatsapp::handler::split_message(&tagged, 4000);
assert!(chunks.len() > 1, "test text must exceed the chunk limit");
assert!(
chunks[0].starts_with(crate::channels::whatsapp::handler::MSG_HEADER),
"lead chunk lost the attribution header"
);
}
#[test]
fn reply_arm_chunks_with_quote_only_on_lead() {
const SRC: &str = include_str!("../brain/tools/whatsapp_send.rs");
let (_, rest) = SRC.split_once("\"reply\" =>").expect("reply arm not found");
let (arm, _) = rest
.split_once("// ── delete")
.expect("delete arm anchor not found");
assert!(
arm.contains("split_message(&tagged, 4000)"),
"reply arm does not chunk the tagged text"
);
assert!(
arm.contains("for (i, chunk) in chunks.into_iter().enumerate()"),
"reply arm does not iterate chunks"
);
assert!(
arm.contains("if i == 0"),
"reply arm does not single out the lead chunk"
);
assert!(
arm.contains("extended_text_message: Some"),
"lead chunk lost the quote message form"
);
assert!(
arm.contains("conversation: Some"),
"follow-up chunks must be plain conversation messages"
);
assert!(
arm.contains("delivered_prefix(&delivered)"),
"reply failure branch lost the #1490-B accounting"
);
assert!(
arm.contains("partial_failure_report("),
"reply failure branch lost the accounting report"
);
assert!(
arm.contains("persist_outgoing(&jid, &tagged)"),
"reply success must persist the tagged form"
);
}