use super::*;
#[test]
fn parse_envelope_slash_command() {
let raw = r#"{
"type": "slash_commands",
"envelope_id": "env-1",
"payload": {
"command": "/fleet",
"text": "",
"channel_id": "C123"
}
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::SlashCommand {
envelope_id: "env-1".into(),
command: "/fleet".into(),
text: String::new(),
channel: "C123".into(),
}
);
}
#[test]
fn parse_envelope_slash_command_with_text() {
let raw = r#"{
"type": "slash_commands",
"envelope_id": "env-2",
"payload": { "command": "/msend", "text": "sess-1 build now", "channel_id": "C9" }
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::SlashCommand {
envelope_id: "env-2".into(),
command: "/msend".into(),
text: "sess-1 build now".into(),
channel: "C9".into(),
}
);
}
#[test]
fn parse_envelope_message() {
let raw = r#"{
"type": "events_api",
"envelope_id": "env-3",
"payload": {
"event": { "type": "message", "text": "spin up a session", "channel": "C7", "user": "U1" }
}
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Message {
envelope_id: "env-3".into(),
text: "spin up a session".into(),
channel: "C7".into(),
thread: None,
}
);
}
#[test]
fn parse_envelope_threaded_message_carries_thread_ts() {
let raw = r#"{
"type": "events_api",
"envelope_id": "env-3t",
"payload": {
"event": { "type": "message", "text": "run the tests", "channel": "C7", "user": "U1", "thread_ts": "169.42" }
}
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Message {
envelope_id: "env-3t".into(),
text: "run the tests".into(),
channel: "C7".into(),
thread: Some("169.42".into()),
}
);
}
#[test]
fn parse_envelope_ignores_bot_message() {
let raw = r#"{
"type": "events_api",
"envelope_id": "env-4",
"payload": {
"event": { "type": "message", "text": "hi", "channel": "C7", "bot_id": "B1" }
}
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Ignored {
envelope_id: Some("env-4".into())
}
);
}
#[test]
fn parse_envelope_ignores_subtype_bot_message() {
let raw = r#"{
"type": "events_api",
"envelope_id": "env-5",
"payload": {
"event": { "type": "message", "subtype": "bot_message", "text": "hi", "channel": "C7" }
}
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Ignored {
envelope_id: Some("env-5".into())
}
);
}
#[test]
fn parse_envelope_ignores_empty_message() {
let raw = r#"{
"type": "events_api",
"envelope_id": "env-6",
"payload": { "event": { "type": "message", "text": " ", "channel": "C7" } }
}"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Ignored {
envelope_id: Some("env-6".into())
}
);
}
#[test]
fn parse_envelope_hello_is_ignored() {
let raw = r#"{ "type": "hello" }"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Ignored { envelope_id: None }
);
}
#[test]
fn parse_envelope_disconnect_surfaces_reason() {
let raw = r#"{ "type": "disconnect", "reason": "refresh_requested" }"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Disconnect {
reason: "refresh_requested".into()
}
);
let raw = r#"{ "type": "disconnect" }"#;
assert_eq!(
parse_envelope(raw),
SlackEvent::Disconnect {
reason: String::new()
}
);
}
#[test]
fn classify_disconnect_reason_permanent_vs_transient() {
for reason in [
"app_deactivated",
"invalid_auth",
"account_inactive",
"token_revoked",
"token_expired",
"not_authed",
"missing_scope",
"no_permission",
] {
assert_eq!(
classify_disconnect_reason(reason),
DisconnectKind::Permanent,
"expected `{reason}` to be permanent",
);
}
assert_eq!(
classify_disconnect_reason("apps.connections.open failed: INVALID_AUTH"),
DisconnectKind::Permanent
);
for reason in [
"refresh_requested",
"warning",
"link_disabled",
"",
"socket gone",
] {
assert_eq!(
classify_disconnect_reason(reason),
DisconnectKind::Transient,
"expected `{reason}` to be transient",
);
}
}
#[test]
fn next_backoff_doubles_and_caps() {
use std::time::Duration;
let cap = Duration::from_secs(60);
assert_eq!(
next_backoff(Duration::from_secs(2), cap),
Duration::from_secs(4)
);
assert_eq!(
next_backoff(Duration::from_secs(4), cap),
Duration::from_secs(8)
);
assert_eq!(next_backoff(Duration::from_secs(40), cap), cap);
assert_eq!(next_backoff(cap, cap), cap);
assert_eq!(next_backoff(Duration::MAX, cap), cap);
}
#[test]
fn parse_envelope_garbage_is_ignored() {
assert_eq!(
parse_envelope("not json"),
SlackEvent::Ignored { envelope_id: None }
);
}
#[test]
fn ack_frame_shape() {
assert_eq!(ack_frame("env-1"), r#"{"envelope_id":"env-1"}"#);
}
#[test]
fn envelope_id_of_extracts_each_variant() {
assert_eq!(
envelope_id_of(&SlackEvent::SlashCommand {
envelope_id: "a".into(),
command: "/x".into(),
text: String::new(),
channel: "C".into(),
}),
Some("a".to_string())
);
assert_eq!(
envelope_id_of(&SlackEvent::Message {
envelope_id: "b".into(),
text: "hi".into(),
channel: "C".into(),
thread: None,
}),
Some("b".to_string())
);
assert_eq!(
envelope_id_of(&SlackEvent::Ignored {
envelope_id: Some("c".into())
}),
Some("c".to_string())
);
assert_eq!(
envelope_id_of(&SlackEvent::Ignored { envelope_id: None }),
None
);
}
#[test]
fn record_chat_turn_caps_history() {
let mut entry: Vec<ChatMessage> = Vec::new();
let turns = MAX_CHAT_HISTORY_TURNS;
let messages_pushed = 2 * turns;
for i in 0..turns {
record_chat_turn(&mut entry, &format!("u{i}"), &format!("a{i}"));
}
assert!(
messages_pushed > MAX_CHAT_HISTORY_TURNS,
"sanity: we must push MORE than the cap so this test is not vacuous"
);
assert_eq!(entry.len(), MAX_CHAT_HISTORY_TURNS);
let first_retained_turn = MAX_CHAT_HISTORY_TURNS / 2;
assert_eq!(entry[0].role, "user");
assert_eq!(entry[0].content, format!("u{first_retained_turn}"));
assert!(
!entry
.iter()
.any(|m| m.content == format!("u{}", first_retained_turn - 1)),
"the turn before the first retained one must have been evicted"
);
let last_user = entry
.iter()
.rev()
.find_map(|m| (m.role == "user").then(|| m.content.clone()));
assert_eq!(
last_user.as_deref(),
Some(&format!("u{}", MAX_CHAT_HISTORY_TURNS - 1)[..])
);
}
#[test]
fn pid_file_guard_removes_on_drop() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slack.pid");
std::fs::write(&path, "12345").expect("write pid");
assert!(path.exists(), "precondition: PID file exists before drop");
{
let _guard = PidFileGuard { path: path.clone() };
} assert!(!path.exists(), "guard must remove the PID file on drop");
}
#[test]
fn pid_file_guard_drop_missing_is_silent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slack.pid");
{
let _guard = PidFileGuard { path: path.clone() };
}
assert!(!path.exists(), "missing PID file stays absent, no panic");
}
#[test]
fn record_chat_turn_skips_empty_reply() {
let mut entry: Vec<ChatMessage> = Vec::new();
record_chat_turn(&mut entry, "hello", "");
assert_eq!(entry.len(), 1);
assert_eq!(entry[0].role, "user");
}
#[test]
fn action_footer_lists_verbs() {
let footer = action_footer(Some(&["sessions.list".into(), "sessions.health".into()]));
assert_eq!(
footer.as_deref(),
Some("\n\n_ran: sessions.list, sessions.health_")
);
}
#[test]
fn action_footer_absent_when_empty() {
assert_eq!(action_footer(None), None);
let empty: Vec<String> = Vec::new();
assert_eq!(action_footer(Some(&empty)), None);
}
#[test]
fn resolve_token_reads_dotenv() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(".env.local");
let mut f = std::fs::File::create(&path).expect("create dotenv");
writeln!(f, "# a comment").unwrap();
writeln!(f, "SLACK_BOT_TOKEN=\"xoxb-secret\"").unwrap();
drop(f);
assert_eq!(
read_dotenv_key(&path, "SLACK_BOT_TOKEN").as_deref(),
Some("xoxb-secret")
);
assert_eq!(read_dotenv_key(&path, "MISSING"), None);
}
#[test]
fn resolve_token_missing_is_none() {
let missing = std::path::Path::new("/nonexistent/.env.local");
assert_eq!(read_dotenv_key(missing, "SLACK_BOT_TOKEN"), None);
}
#[test]
fn pid_file_path_is_under_framework_root() {
let path = pid_file_path();
assert!(path.ends_with("slack.pid"));
assert!(
path.to_string_lossy()
.contains(crate::core::paths::FRAMEWORK_DIR_NAME)
);
}
#[test]
fn stop_via_pid_file_missing_is_not_running() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slack.pid");
assert_eq!(stop_via_pid_file(&path), StopOutcome::NotRunning);
}
#[test]
fn stop_via_pid_file_garbage_is_failed_and_removes_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slack.pid");
std::fs::write(&path, "not-a-pid").expect("write pid");
match stop_via_pid_file(&path) {
StopOutcome::Failed(_) => {}
other => panic!("expected Failed, got {other:?}"),
}
assert!(!path.exists(), "corrupt PID file should be removed");
}
#[cfg(unix)]
#[test]
fn stop_via_pid_file_stale_pid_is_not_running() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("slack.pid");
std::fs::write(&path, "999999999").expect("write pid");
assert_eq!(stop_via_pid_file(&path), StopOutcome::NotRunning);
assert!(!path.exists(), "stale PID file should be removed");
}
#[tokio::test]
async fn build_slack_client_bounds_a_stalled_connection() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stalling listener");
let addr = listener.local_addr().expect("read local_addr");
let client = build_slack_client(
std::time::Duration::from_millis(200),
std::time::Duration::from_millis(300),
);
let url = format!("http://{addr}/");
let start = std::time::Instant::now();
let result = client.get(&url).send().await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected the stalled request to time out");
assert!(
elapsed < std::time::Duration::from_secs(5),
"request took {elapsed:?}, expected it to be bounded by the ~300ms timeout"
);
}
#[test]
fn slack_client_timeout_constants_are_finite() {
assert_eq!(SLACK_CONNECT_TIMEOUT_SECS, 10);
assert_eq!(SLACK_REQUEST_TIMEOUT_SECS, 30);
}
use crate::client::{ActivityDigest, FocusTarget};
struct RecordingBackend {
calls: Mutex<Vec<String>>,
resolve_ok: bool,
}
impl RecordingBackend {
fn new(resolve_ok: bool) -> Self {
Self {
calls: Mutex::new(Vec::new()),
resolve_ok,
}
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl ManagedBackend for RecordingBackend {
async fn resolve(&self, target: &str) -> Result<(String, String), String> {
self.calls.lock().unwrap().push(format!("resolve:{target}"));
if self.resolve_ok {
Ok((format!("id-{target}"), target.to_string()))
} else {
Err(format!("managed session {target} not found"))
}
}
async fn send(&self, id: &str, text: &str) -> Result<(), String> {
self.calls.lock().unwrap().push(format!("send:{id}:{text}"));
Ok(())
}
async fn activity(&self, id: &str) -> Result<ActivityDigest, String> {
self.calls.lock().unwrap().push(format!("activity:{id}"));
Ok(ActivityDigest {
state: "active".into(),
summary: "running cargo test".into(),
pending_decision: None,
})
}
}
fn offline_executor() -> CommandExecutor {
CommandExecutor::new("http://127.0.0.1:0")
}
fn empty_histories() -> ChatHistories {
Arc::new(Mutex::new(HashMap::new()))
}
fn slash(command: &str, text: &str, channel: &str) -> SlackEvent {
SlackEvent::SlashCommand {
envelope_id: "e".into(),
command: command.into(),
text: text.into(),
channel: channel.into(),
}
}
fn message(text: &str, channel: &str, thread: Option<&str>) -> SlackEvent {
SlackEvent::Message {
envelope_id: "e".into(),
text: text.into(),
channel: channel.into(),
thread: thread.map(str::to_string),
}
}
#[tokio::test]
async fn slash_focus_reaches_proxy_resolve() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let executor = offline_executor();
let histories = empty_histories();
let (channel, body) =
reply_for_event(&executor, &histories, &proxy, &slash("/focus", "api", "C1"))
.await
.expect("slash command yields a reply");
assert_eq!(channel, "C1");
assert!(body.contains("Focused on *api*"), "{body}");
assert_eq!(backend.calls(), vec!["resolve:api".to_string()]);
assert_eq!(
proxy.current_focus(&focus::conv("C1", None)),
Some(FocusTarget {
id: "id-api".into(),
name: "api".into(),
}),
);
}
#[tokio::test]
async fn message_when_focused_reaches_proxy_send() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let executor = offline_executor();
let histories = empty_histories();
reply_for_event(&executor, &histories, &proxy, &slash("/focus", "api", "C1"))
.await
.expect("focus reply");
let (_channel, body) = reply_for_event(
&executor,
&histories,
&proxy,
&message("run tests", "C1", None),
)
.await
.expect("message yields a reply");
assert!(body.contains("run tests"), "{body}");
assert!(
backend
.calls()
.contains(&"send:id-api:run tests".to_string()),
"expected a send call, got {:?}",
backend.calls()
);
}
#[tokio::test]
async fn slash_summary_reaches_proxy_activity() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let executor = offline_executor();
let histories = empty_histories();
reply_for_event(&executor, &histories, &proxy, &slash("/focus", "api", "C1"))
.await
.expect("focus reply");
let (_channel, body) =
reply_for_event(&executor, &histories, &proxy, &slash("/summary", "", "C1"))
.await
.expect("summary reply");
assert!(body.contains("active"), "{body}");
assert!(body.contains("running cargo test"), "{body}");
assert!(
backend.calls().contains(&"activity:id-api".to_string()),
"expected an activity call, got {:?}",
backend.calls()
);
}
#[tokio::test]
async fn slash_unfocus_reaches_proxy() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let executor = offline_executor();
let histories = empty_histories();
reply_for_event(&executor, &histories, &proxy, &slash("/focus", "api", "C1"))
.await
.expect("focus reply");
let (_channel, body) =
reply_for_event(&executor, &histories, &proxy, &slash("/unfocus", "", "C1"))
.await
.expect("unfocus reply");
assert!(body.contains("Unfocused"), "{body}");
assert!(proxy.current_focus(&focus::conv("C1", None)).is_none());
}
#[tokio::test]
async fn focus_is_scoped_per_thread() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let thread_conv = focus::conv("C1", Some("169.42"));
proxy.focus(&thread_conv, "api").await;
assert!(proxy.current_focus(&thread_conv).is_some());
assert!(proxy.current_focus(&focus::conv("C1", None)).is_none());
}
#[tokio::test]
async fn threaded_reply_falls_back_to_channel_focus() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let executor = offline_executor();
let histories = empty_histories();
reply_for_event(&executor, &histories, &proxy, &slash("/focus", "api", "C1"))
.await
.expect("focus reply");
let (_channel, body) = reply_for_event(
&executor,
&histories,
&proxy,
&message("run tests", "C1", Some("169.42")),
)
.await
.expect("threaded message yields a reply");
assert!(body.contains("run tests"), "{body}");
assert!(
backend
.calls()
.contains(&"send:id-api:run tests".to_string()),
"expected the threaded reply to inject via the channel's focus, got {:?}",
backend.calls()
);
}
#[tokio::test]
async fn thread_focus_does_not_leak_to_parent_channel_message() {
let backend = Arc::new(RecordingBackend::new(true));
let proxy = SessionProxy::new(backend.clone() as Arc<dyn ManagedBackend>);
let thread_conv = focus::conv("C1", Some("169.42"));
proxy.focus(&thread_conv, "api").await;
let executor = offline_executor();
let histories = empty_histories();
let _ = reply_for_event(
&executor,
&histories,
&proxy,
&message("run tests", "C1", None),
)
.await;
assert!(
!backend.calls().iter().any(|c| c.starts_with("send:")),
"a thread-scoped focus must never satisfy a non-threaded channel message, got {:?}",
backend.calls()
);
}