use crate::brain::tools::whatsapp_send::{StatusFanOut, StatusSink, fan_out_status, status_report};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use wacore_binary::jid::Jid;
const PACE: Duration = Duration::from_secs(5);
fn jids(n: usize) -> Vec<Jid> {
(1..=n)
.map(|i| {
format!("1555123{i:04}@s.whatsapp.net")
.parse()
.expect("test JID")
})
.collect()
}
#[derive(Debug, PartialEq)]
enum Step {
Charge(String),
Send(String, Duration),
}
struct Recorder {
cap: usize,
charged: usize,
start: tokio::time::Instant,
steps: Arc<Mutex<Vec<Step>>>,
fail_for: Option<String>,
}
impl Recorder {
fn new(cap: usize) -> (Self, Arc<Mutex<Vec<Step>>>) {
let steps = Arc::new(Mutex::new(Vec::new()));
(
Self {
cap,
charged: 0,
start: tokio::time::Instant::now(),
steps: Arc::clone(&steps),
fail_for: None,
},
steps,
)
}
}
#[async_trait]
impl StatusSink for Recorder {
async fn charge(&mut self, jid: &Jid) -> bool {
if self.charged >= self.cap {
return false;
}
self.charged += 1;
self.steps
.lock()
.expect("step log")
.push(Step::Charge(jid.to_string()));
true
}
async fn deliver(&mut self, jid: &Jid) -> Result<(), String> {
let at = tokio::time::Instant::now().saturating_duration_since(self.start);
self.steps
.lock()
.expect("step log")
.push(Step::Send(jid.to_string(), at));
match &self.fail_for {
Some(target) if target == &jid.to_string() => Err("stanza rejected".to_string()),
_ => Ok(()),
}
}
}
fn send_offsets(steps: &Arc<Mutex<Vec<Step>>>) -> Vec<Duration> {
steps
.lock()
.expect("step log")
.iter()
.filter_map(|s| match s {
Step::Send(_, at) => Some(*at),
Step::Charge(_) => None,
})
.collect()
}
#[tokio::test(start_paused = true)]
async fn the_pace_sits_between_sends_not_before_the_first() {
let targets = jids(3);
let (mut sink, steps) = Recorder::new(10);
let out = fan_out_status(&mut sink, &targets, PACE).await;
assert_eq!(out.sent.len(), 3, "every recipient got a status");
assert_eq!(
send_offsets(&steps),
vec![Duration::ZERO, PACE, PACE * 2],
"the first status goes out immediately and the rest one pace apart"
);
}
#[tokio::test(start_paused = true)]
async fn a_cap_refusal_spends_no_slot_on_an_undelivered_status() {
let targets = jids(3);
let (mut sink, steps) = Recorder::new(2);
let out = fan_out_status(&mut sink, &targets, PACE).await;
assert_eq!(out.sent.len(), 2, "the cap allowed two statuses");
assert_eq!(
out.refused,
vec![targets[2].to_string()],
"the third recipient is refused by name"
);
assert!(out.failed.is_empty(), "a refusal is not a send failure");
assert_eq!(
sink.charged, 2,
"one slot per delivered status, none for the refused one"
);
let charges = steps
.lock()
.expect("step log")
.iter()
.filter(|s| matches!(s, Step::Charge(_)))
.count();
assert_eq!(charges, 2, "the refused recipient was never charged");
}
#[tokio::test(start_paused = true)]
async fn a_cap_refusal_names_every_skipped_recipient() {
let targets = jids(3);
let (mut sink, _steps) = Recorder::new(1);
let out = fan_out_status(&mut sink, &targets, PACE).await;
let report = status_report("text status", targets.len(), &out, PACE);
assert_eq!(
out.refused,
vec![targets[1].to_string(), targets[2].to_string()],
"both skipped recipients are listed"
);
assert!(report.success, "a partial status is still worth reporting");
let text = report.output;
assert!(
text.contains("posted to 1 of 3"),
"states the split instead of claiming a batch success: {text}"
);
assert!(
text.contains(&targets[1].to_string()) && text.contains(&targets[2].to_string()),
"names the recipients a retry still owes: {text}"
);
}
#[tokio::test(start_paused = true)]
async fn one_failed_send_does_not_stop_the_rest() {
let targets = jids(3);
let (mut sink, _steps) = Recorder::new(10);
sink.fail_for = Some(targets[1].to_string());
let out = fan_out_status(&mut sink, &targets, PACE).await;
assert_eq!(
out.sent,
vec![targets[0].to_string(), targets[2].to_string()],
"the first and third still received it"
);
assert_eq!(out.failed.len(), 1, "the middle recipient failed");
assert_eq!(out.failed[0].0, targets[1].to_string());
assert!(
out.refused.is_empty(),
"a send failure is not a cap refusal"
);
}
#[test]
fn a_fan_out_that_delivered_nothing_is_an_error() {
let targets = jids(2);
let out = StatusFanOut {
sent: Vec::new(),
refused: targets
.iter()
.map(std::string::ToString::to_string)
.collect(),
failed: Vec::new(),
};
let report = status_report("text status", targets.len(), &out, PACE);
assert!(!report.success, "no delivery is a failure");
let err = report.error.unwrap_or_default();
assert!(err.contains("reached none of the 2"), "{err}");
assert!(
err.contains("not queued for retry"),
"a status cannot ride the text queue, so say so: {err}"
);
}
#[tokio::test(start_paused = true)]
async fn status_sends_once_per_recipient() {
let targets = jids(3);
let (mut sink, steps) = Recorder::new(10);
let out = fan_out_status(&mut sink, &targets, PACE).await;
let sends: Vec<String> = steps
.lock()
.expect("step log")
.iter()
.filter_map(|s| match s {
Step::Send(jid, _) => Some(jid.clone()),
Step::Charge(_) => None,
})
.collect();
assert_eq!(
sends,
targets
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
"one send per recipient, in order, instead of one send for the whole list"
);
assert_eq!(out.sent, sends, "every send is reported as delivered");
}