use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use ircbot::bot::run_bot_internal;
use ircbot::handler::{HandlerEntry, HandlerFn, Trigger};
use ircbot::{BoxFuture, Context, State};
struct MockServer {
addr: String,
to_bot: mpsc::UnboundedSender<String>,
from_bot: mpsc::UnboundedReceiver<String>,
}
impl MockServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (to_bot_tx, mut to_bot_rx) = mpsc::unbounded_channel::<String>();
let (from_bot_tx, from_bot_rx) = mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
let (sock, _) = listener.accept().await.unwrap();
let (read_half, mut write_half) = sock.into_split();
tokio::spawn(async move {
while let Some(line) = to_bot_rx.recv().await {
if write_half.write_all(line.as_bytes()).await.is_err() {
break;
}
if write_half.flush().await.is_err() {
break;
}
}
});
let mut reader = BufReader::new(read_half).lines();
while let Ok(Some(line)) = reader.next_line().await {
if from_bot_tx.send(line).is_err() {
break;
}
}
});
MockServer {
addr,
to_bot: to_bot_tx,
from_bot: from_bot_rx,
}
}
fn send(&self, line: &str) {
self.to_bot.send(line.to_string()).unwrap();
}
fn send_welcome(&self) {
self.send(":server 001 testbot :Welcome to the mock IRC server\r\n");
}
async fn expect_line(&mut self, predicate: impl Fn(&str) -> bool) -> String {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let line = self
.from_bot
.recv()
.await
.expect("bot connection closed before expected line");
if predicate(&line) {
return line;
}
}
})
.await
.expect("timed out waiting for expected line from bot")
}
async fn expect_no_line(&mut self, window: Duration, predicate: impl Fn(&str) -> bool) {
let res = tokio::time::timeout(window, async {
loop {
match self.from_bot.recv().await {
Some(line) if predicate(&line) => return Some(line),
Some(_) => continue,
None => return None,
}
}
})
.await;
if let Ok(Some(line)) = res {
panic!("unexpected line written by bot: {line:?}");
}
}
}
async fn spawn_bot<T: Send + Sync + 'static>(
addr: &str,
bot: Arc<T>,
handlers: Vec<HandlerEntry<T>>,
) -> tokio::task::JoinHandle<Result<(), ircbot::BoxError>> {
let state = State::connect("testbot".to_string(), addr, vec![])
.await
.expect("failed to connect to mock server");
let handler_set = ircbot::internal::make_handler_set(handlers);
tokio::spawn(run_bot_internal(bot, state, handler_set))
}
async fn spawn_bot_with<T: Send + Sync + 'static>(
addr: &str,
channels: Vec<String>,
keepalive: Option<(Duration, Duration)>,
bot: Arc<T>,
handlers: Vec<HandlerEntry<T>>,
) -> tokio::task::JoinHandle<Result<(), ircbot::BoxError>> {
let mut state = State::connect("testbot".to_string(), addr, channels)
.await
.expect("failed to connect to mock server");
if let Some((interval, timeout)) = keepalive {
state = state.with_keepalive(interval, timeout);
}
let handler_set = ircbot::internal::make_handler_set(handlers);
tokio::spawn(run_bot_internal(bot, state, handler_set))
}
fn no_handlers() -> Vec<HandlerEntry<()>> {
vec![]
}
#[tokio::test]
async fn ctcp_ping_with_arg_gets_notice_reply() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send(":alice!a@h PRIVMSG testbot :\x01PING 12345\x01\r\n");
let line = server.expect_line(|l| l.starts_with("NOTICE")).await;
assert_eq!(line, "NOTICE alice :\x01PING 12345\x01");
bot_task.abort();
}
#[tokio::test]
async fn ctcp_ping_without_arg_gets_notice_reply() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send(":alice!a@h PRIVMSG testbot :\x01PING\x01\r\n");
let line = server.expect_line(|l| l.starts_with("NOTICE")).await;
assert_eq!(line, "NOTICE alice :\x01PING\x01");
bot_task.abort();
}
#[tokio::test]
async fn ctcp_version_gets_notice_reply() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send(":alice!a@h PRIVMSG testbot :\x01VERSION\x01\r\n");
let line = server.expect_line(|l| l.starts_with("NOTICE")).await;
assert!(
line.starts_with("NOTICE alice :\x01VERSION ircbot "),
"unexpected VERSION reply: {line:?}"
);
assert!(
line.ends_with('\x01'),
"VERSION reply must be CTCP-terminated: {line:?}"
);
bot_task.abort();
}
#[tokio::test]
async fn ctcp_ping_without_sender_produces_no_reply() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send(":irc.server PRIVMSG testbot :\x01PING 999\x01\r\n");
server
.expect_no_line(Duration::from_millis(400), |l| l.starts_with("NOTICE"))
.await;
bot_task.abort();
}
#[tokio::test]
async fn server_ping_gets_pong_reply() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send("PING :server.test\r\n");
let line = server.expect_line(|l| l.starts_with("PONG")).await;
assert_eq!(line, "PONG :server.test");
bot_task.abort();
}
#[tokio::test]
async fn joins_all_channels_on_welcome_once() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot_with(
&server.addr,
vec!["#a".to_string(), "#b".to_string()],
None,
Arc::new(()),
no_handlers(),
)
.await;
server.send_welcome();
let first = server.expect_line(|l| l.starts_with("JOIN")).await;
let second = server.expect_line(|l| l.starts_with("JOIN")).await;
let mut joins = [first, second];
joins.sort();
assert_eq!(joins, ["JOIN #a".to_string(), "JOIN #b".to_string()]);
server.send_welcome();
server
.expect_no_line(Duration::from_millis(400), |l| l.starts_with("JOIN"))
.await;
bot_task.abort();
}
#[tokio::test]
async fn nick_in_use_retries_with_underscore_suffixes() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send(":server 433 * testbot :Nickname is already in use\r\n");
let first = server.expect_line(|l| l == "NICK testbot_").await;
assert_eq!(first, "NICK testbot_");
server.send(":server 433 * testbot_ :Nickname is already in use\r\n");
let second = server.expect_line(|l| l == "NICK testbot__").await;
assert_eq!(second, "NICK testbot__");
bot_task.abort();
}
#[tokio::test]
async fn unavailresource_437_also_triggers_nick_retry() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send(":server 437 * testbot :Nick/channel is temporarily unavailable\r\n");
let line = server.expect_line(|l| l == "NICK testbot_").await;
assert_eq!(line, "NICK testbot_");
bot_task.abort();
}
#[tokio::test]
async fn nick_in_use_after_welcome_does_not_renegotiate() {
let mut server = MockServer::start().await;
let bot_task = spawn_bot(&server.addr, Arc::new(()), no_handlers()).await;
server.send_welcome();
server.send(":server 433 * testbot :Nickname is already in use\r\n");
server
.expect_no_line(Duration::from_millis(400), |l| {
l.starts_with("NICK testbot_")
})
.await;
bot_task.abort();
}
async fn keepalive_survives(pong_line: &str) -> bool {
let mut server = MockServer::start().await;
let bot_task = spawn_bot_with(
&server.addr,
vec![],
Some((Duration::from_millis(300), Duration::from_millis(300))),
Arc::new(()),
no_handlers(),
)
.await;
server.send_welcome();
server.expect_line(|l| l.contains("ircbot-keepalive")).await;
server.send(pong_line);
tokio::time::sleep(Duration::from_millis(800)).await;
let finished = bot_task.is_finished();
bot_task.abort();
!finished
}
#[tokio::test]
async fn keepalive_pong_token_in_trailing_position_is_accepted() {
assert!(keepalive_survives("PONG irc.server :ircbot-keepalive\r\n").await);
}
#[tokio::test]
async fn keepalive_pong_token_in_first_position_is_accepted() {
assert!(keepalive_survives("PONG :ircbot-keepalive\r\n").await);
}
#[tokio::test]
async fn keepalive_wrong_pong_token_triggers_timeout() {
assert!(!keepalive_survives("PONG irc.server :not-the-token\r\n").await);
}
#[tokio::test]
async fn all_matching_handlers_fire_in_registration_order() {
let mut server = MockServer::start().await;
fn say_handler(text: &'static str) -> HandlerFn<()> {
Box::new(
move |_bot: Arc<()>, ctx: Context| -> BoxFuture<ircbot::Result> {
Box::pin(async move { ctx.say(text) })
},
)
}
let handlers = vec![
HandlerEntry {
trigger: Trigger::Message {
pattern: "go".to_string(),
target: None,
},
handler: say_handler("first"),
},
HandlerEntry {
trigger: Trigger::Message {
pattern: "go".to_string(),
target: None,
},
handler: say_handler("second"),
},
];
let bot_task = spawn_bot(&server.addr, Arc::new(()), handlers).await;
server.send_welcome();
server.send(":alice!a@h PRIVMSG #chan :go\r\n");
let first = server.expect_line(|l| l.starts_with("PRIVMSG")).await;
let second = server.expect_line(|l| l.starts_with("PRIVMSG")).await;
assert_eq!(first, "PRIVMSG #chan :first");
assert_eq!(second, "PRIVMSG #chan :second");
bot_task.abort();
}
#[tokio::test]
async fn handler_error_does_not_prevent_later_handlers() {
let mut server = MockServer::start().await;
let erroring: HandlerFn<()> = Box::new(
|_bot: Arc<()>, _ctx: Context| -> BoxFuture<ircbot::Result> {
Box::pin(async move { Err("boom".into()) })
},
);
let healthy: HandlerFn<()> =
Box::new(|_bot: Arc<()>, ctx: Context| -> BoxFuture<ircbot::Result> {
Box::pin(async move { ctx.say("still here") })
});
let handlers = vec![
HandlerEntry {
trigger: Trigger::Message {
pattern: "go".to_string(),
target: None,
},
handler: erroring,
},
HandlerEntry {
trigger: Trigger::Message {
pattern: "go".to_string(),
target: None,
},
handler: healthy,
},
];
let bot_task = spawn_bot(&server.addr, Arc::new(()), handlers).await;
server.send_welcome();
server.send(":alice!a@h PRIVMSG #chan :go\r\n");
let line = server.expect_line(|l| l.starts_with("PRIVMSG")).await;
assert_eq!(line, "PRIVMSG #chan :still here");
bot_task.abort();
}
fn sender_capturing_handler(slot: Arc<Mutex<Option<Option<String>>>>) -> HandlerEntry<()> {
HandlerEntry {
trigger: Trigger::Event {
event: "PRIVMSG".to_string(),
target: None,
regex: None,
},
handler: Box::new(
move |_bot: Arc<()>, ctx: Context| -> BoxFuture<ircbot::Result> {
let slot = Arc::clone(&slot);
Box::pin(async move {
let mut guard = slot.lock().unwrap();
if guard.is_none() {
*guard = Some(ctx.sender.as_ref().map(|u| u.nick.clone()));
}
Ok(())
})
},
),
}
}
#[tokio::test]
async fn sender_populated_for_nick_user_host_prefix() {
let server = MockServer::start().await;
let slot: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
let bot_task = spawn_bot(
&server.addr,
Arc::new(()),
vec![sender_capturing_handler(Arc::clone(&slot))],
)
.await;
server.send_welcome();
server.send(":alice!user@host PRIVMSG #chan :hi\r\n");
wait_for_some(&slot).await;
assert_eq!(
slot.lock().unwrap().clone().unwrap(),
Some("alice".to_string())
);
bot_task.abort();
}
#[tokio::test]
async fn sender_none_for_server_prefix() {
let server = MockServer::start().await;
let slot: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
let bot_task = spawn_bot(
&server.addr,
Arc::new(()),
vec![sender_capturing_handler(Arc::clone(&slot))],
)
.await;
server.send_welcome();
server.send(":irc.server PRIVMSG #chan :hi\r\n");
wait_for_some(&slot).await;
assert_eq!(slot.lock().unwrap().clone().unwrap(), None);
bot_task.abort();
}
async fn wait_for_some<V>(slot: &Arc<Mutex<Option<V>>>) {
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if slot.lock().unwrap().is_some() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("handler did not fire within 2 s");
}