diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae53222..fbf80c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Testing +- test(channels): add injectable test transport to `TelegramChannel` (#2121) — `new_test()` constructor under `#[cfg(test)]` exposes an `mpsc::Sender` so all channel behavioral paths can be tested without a real bot token or live Telegram API; 12 new tests cover `recv()` message delivery, `/reset` and `/skills` command routing, unknown-command passthrough, channel-close returning `None`, text accumulation in `send_chunk()`, `flush_chunks()` state clearing, the `/start` welcome path via wiremock, `flush_chunks()` with `message_id` via wiremock, and `confirm()` timeout/close/yes/no logic at the rx-timeout level; adds `wiremock` and tokio `test-util` to dev-dependencies - test(tools): add integration tests for `FileExecutor` sandbox access controls (#2117) — 15 tests in `crates/zeph-tools/tests/file_access.rs` covering read/write inside sandbox, sandbox violation on outside paths, symlink escape (single and chained, unix-only), path traversal blocking, multiple allowed paths, empty allowed-paths CWD fallback, tilde regression (#2115), delete/move/copy cross-boundary blocking, `find_path` result filtering to sandbox, `grep` default-path sandbox validation, and nonexistent allowed path resilience - test(cost): add unit test for `max_daily_cents = 0.0` unlimited budget behavior — `CostTracker::check_budget()` must return `Ok(())` regardless of spend when the daily limit is zero (#2110) - chore(testing): add canonical `config/testing.toml` with `provider = "router"` to enable RAPS/reputation scoring in CI sessions (#2104) — previously `.local/config/testing.toml` used `provider = "openai"` which silently ignored `[llm.router]` and `[llm.router.reputation]`; the new tracked reference config uses `provider = "router"` with `chain = ["openai"]` keeping identical LLM behavior while activating RAPS; copy to `.local/config/testing.toml` before use diff --git a/Cargo.lock b/Cargo.lock index 30a3bc2d..6bd594d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9414,6 +9414,7 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tracing", "unicode-width", + "wiremock", "zeph-core", ] diff --git a/crates/zeph-channels/Cargo.toml b/crates/zeph-channels/Cargo.toml index 6e5ed87b..f78592a4 100644 --- a/crates/zeph-channels/Cargo.toml +++ b/crates/zeph-channels/Cargo.toml @@ -43,6 +43,8 @@ harness = false [dev-dependencies] criterion.workspace = true tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } +wiremock.workspace = true [lints] workspace = true diff --git a/crates/zeph-channels/src/telegram.rs b/crates/zeph-channels/src/telegram.rs index 95571689..a637ba00 100644 --- a/crates/zeph-channels/src/telegram.rs +++ b/crates/zeph-channels/src/telegram.rs @@ -181,6 +181,29 @@ impl TelegramChannel { Ok(self) } + /// Creates a `TelegramChannel` with an injectable sender for unit tests. + /// + /// The returned `Sender` allows injecting `IncomingMessage` values directly + /// without a real Telegram bot token or live API access. The bot is + /// initialized with a dummy token and `chat_id` is left unset; tests that + /// exercise paths which call the bot API (e.g. `send()`, `confirm()`) must + /// either avoid those code paths or configure a mock HTTP server via + /// `Bot::set_api_url`. + #[cfg(test)] + fn new_test(allowed_users: Vec) -> (Self, mpsc::Sender) { + let (tx, rx) = mpsc::channel(64); + let channel = Self { + bot: Bot::new("test_token"), + chat_id: None, + rx, + allowed_users, + accumulated: String::new(), + last_edit: None, + message_id: None, + }; + (channel, tx) + } + fn is_command(text: &str) -> Option<&str> { let cmd = text.split_whitespace().next()?; if cmd.starts_with('/') { @@ -450,8 +473,68 @@ impl Channel for TelegramChannel { #[cfg(test)] mod tests { + use std::time::Instant; + + use wiremock::matchers::any; + use wiremock::{Mock, MockServer, ResponseTemplate}; + use super::*; + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Minimal valid Telegram `sendMessage` / `editMessageText` response. + fn tg_ok_message() -> serde_json::Value { + serde_json::json!({ + "ok": true, + "result": { + "message_id": 42, + "date": 1_700_000_000_i64, + "chat": {"id": 1, "type": "private"} + } + }) + } + + /// Creates a `TelegramChannel` whose bot is pointed at `server` so that + /// any API call the channel makes is intercepted rather than going to the + /// real Telegram endpoint. + async fn make_mocked_channel( + server: &MockServer, + allowed_users: Vec, + ) -> (TelegramChannel, mpsc::Sender) { + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_json(tg_ok_message())) + .mount(server) + .await; + + let api_url = reqwest::Url::parse(&server.uri()).unwrap(); + let bot = Bot::new("test_token").set_api_url(api_url); + let (tx, rx) = mpsc::channel(64); + let channel = TelegramChannel { + bot, + chat_id: Some(ChatId(1)), + rx, + allowed_users, + accumulated: String::new(), + last_edit: None, + message_id: None, + }; + (channel, tx) + } + + fn plain_message(text: &str) -> IncomingMessage { + IncomingMessage { + chat_id: ChatId(1), + text: text.to_string(), + attachments: vec![], + } + } + + // --------------------------------------------------------------------------- + // Pure-function unit tests (no async, no network) + // --------------------------------------------------------------------------- + #[test] fn is_command_detection() { assert_eq!(TelegramChannel::is_command("/start"), Some("/start")); @@ -462,47 +545,111 @@ mod tests { #[test] fn should_send_update_first_chunk() { - let token = "test_token".to_string(); - let allowed_users = Vec::new(); - let channel = TelegramChannel::new(token, allowed_users); + let channel = TelegramChannel::new("test_token".to_string(), Vec::new()); assert!(channel.should_send_update()); } #[test] fn should_send_update_time_threshold() { - let token = "test_token".to_string(); - let allowed_users = Vec::new(); - let mut channel = TelegramChannel::new(token, allowed_users); + let mut channel = TelegramChannel::new("test_token".to_string(), Vec::new()); channel.accumulated = "test".to_string(); - // Set last_edit to 11 seconds ago (threshold is 10 seconds) channel.last_edit = Some(Instant::now().checked_sub(Duration::from_secs(11)).unwrap()); assert!(channel.should_send_update()); } + #[test] + fn should_not_send_update_within_threshold() { + let mut channel = TelegramChannel::new("test_token".to_string(), Vec::new()); + channel.last_edit = Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap()); + assert!(!channel.should_send_update()); + } + + #[test] + fn max_image_bytes_is_20_mib() { + assert_eq!(MAX_IMAGE_BYTES, 20 * 1024 * 1024); + } + + #[test] + fn photo_size_limit_enforcement() { + assert!(MAX_IMAGE_BYTES - 1 <= MAX_IMAGE_BYTES); + assert!(MAX_IMAGE_BYTES <= MAX_IMAGE_BYTES); + assert!(MAX_IMAGE_BYTES + 1 > MAX_IMAGE_BYTES); + } + + #[test] + fn start_rejects_empty_allowed_users() { + let result = TelegramChannel::new("test_token".to_string(), Vec::new()).start(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ChannelError::Other(_))); + } + + // --------------------------------------------------------------------------- + // recv() — injectable sender tests (no network calls) + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn recv_returns_channel_message_when_injected() { + let (mut channel, tx) = TelegramChannel::new_test(vec![]); + tx.send(plain_message("hello world")).await.unwrap(); + let msg = channel.recv().await.unwrap().unwrap(); + assert_eq!(msg.text, "hello world"); + assert!(msg.attachments.is_empty()); + } + #[tokio::test] - async fn send_chunk_accumulates() { - let token = "test_token".to_string(); - let allowed_users = Vec::new(); - let mut channel = TelegramChannel::new(token, allowed_users); + async fn recv_reset_command_routed_correctly() { + let (mut channel, tx) = TelegramChannel::new_test(vec![]); + tx.send(plain_message("/reset")).await.unwrap(); + let msg = channel.recv().await.unwrap().unwrap(); + assert_eq!(msg.text, "/reset"); + } - // Manually set chat_id to avoid send_or_edit failure - // In real tests, this would be set by recv() - channel.accumulated.push_str("hello"); - channel.accumulated.push(' '); - channel.accumulated.push_str("world"); + #[tokio::test] + async fn recv_skills_command_routed_correctly() { + let (mut channel, tx) = TelegramChannel::new_test(vec![]); + tx.send(plain_message("/skills")).await.unwrap(); + let msg = channel.recv().await.unwrap().unwrap(); + assert_eq!(msg.text, "/skills"); + } - assert_eq!(channel.accumulated, "hello world"); + #[tokio::test] + async fn recv_unknown_command_passed_through() { + let (mut channel, tx) = TelegramChannel::new_test(vec![]); + tx.send(plain_message("/unknown_cmd arg")).await.unwrap(); + let msg = channel.recv().await.unwrap().unwrap(); + assert_eq!(msg.text, "/unknown_cmd arg"); } #[tokio::test] - async fn flush_chunks_clears_state() { - let token = "test_token".to_string(); - let allowed_users = Vec::new(); - let mut channel = TelegramChannel::new(token, allowed_users); + async fn recv_returns_none_when_sender_dropped() { + let (mut channel, tx) = TelegramChannel::new_test(vec![]); + drop(tx); + let result = channel.recv().await.unwrap(); + assert!(result.is_none()); + } - channel.accumulated = "test".to_string(); + // --------------------------------------------------------------------------- + // send_chunk() / flush_chunks() — accumulation (no network calls) + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn send_chunk_accumulates_text_without_api_call() { + let (mut channel, _tx) = TelegramChannel::new_test(vec![]); + // Suppress the API call by setting last_edit within the 10-second threshold. + channel.last_edit = Some(Instant::now()); + + channel.send_chunk("hello").await.unwrap(); + channel.send_chunk(" world").await.unwrap(); + + assert_eq!(channel.accumulated, "hello world"); + } + + #[tokio::test] + async fn flush_chunks_clears_state_when_no_message_id() { + let (mut channel, _tx) = TelegramChannel::new_test(vec![]); + channel.accumulated = "some text".to_string(); channel.last_edit = Some(Instant::now()); - // Do not set message_id to avoid triggering send_or_edit() + // message_id is None, so flush_chunks does not call send_or_edit. channel.flush_chunks().await.unwrap(); @@ -511,40 +658,91 @@ mod tests { assert!(channel.message_id.is_none()); } - #[test] - fn max_image_bytes_is_20_mib() { - assert_eq!(MAX_IMAGE_BYTES, 20 * 1024 * 1024); + // --------------------------------------------------------------------------- + // recv(/start) — mock HTTP server required to intercept the welcome send() + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn recv_start_consumed_internally_without_returning_to_caller() { + let server = MockServer::start().await; + let (mut channel, tx) = make_mocked_channel(&server, vec![]).await; + + // /start is consumed; recv() loops and waits for the next message. + tx.send(plain_message("/start")).await.unwrap(); + tx.send(plain_message("hello after start")).await.unwrap(); + + let msg = channel.recv().await.unwrap().unwrap(); + assert_eq!(msg.text, "hello after start"); } - #[test] - fn photo_size_limit_enforcement() { - // Mirrors the guard in the photo extraction handler: - // photos.iter().max_by_key(|p| p.file.size) followed by - // if photo.file.size > MAX_IMAGE_BYTES { skip } else { download } - let size_within_limit: u32 = MAX_IMAGE_BYTES - 1; - let size_at_limit: u32 = MAX_IMAGE_BYTES; - let size_over_limit: u32 = MAX_IMAGE_BYTES + 1; + // --------------------------------------------------------------------------- + // flush_chunks() with message_id set — mock HTTP server required + // --------------------------------------------------------------------------- - assert!(size_within_limit <= MAX_IMAGE_BYTES); - assert!(size_at_limit <= MAX_IMAGE_BYTES); - assert!(size_over_limit > MAX_IMAGE_BYTES); + #[tokio::test] + async fn flush_chunks_calls_edit_and_clears_state_when_message_id_set() { + let server = MockServer::start().await; + let (mut channel, _tx) = make_mocked_channel(&server, vec![]).await; + + channel.accumulated = "partial response".to_string(); + channel.last_edit = Some(Instant::now()); + channel.message_id = Some(teloxide::types::MessageId(42)); + + channel.flush_chunks().await.unwrap(); + + assert!(channel.accumulated.is_empty()); + assert!(channel.last_edit.is_none()); + assert!(channel.message_id.is_none()); } - #[test] - fn should_not_send_update_within_threshold() { - let token = "test_token".to_string(); - let allowed_users = Vec::new(); - let mut channel = TelegramChannel::new(token, allowed_users); - // Set last_edit to 1 second ago (well within the 10-second threshold) - channel.last_edit = Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap()); - assert!(!channel.should_send_update()); + // --------------------------------------------------------------------------- + // confirm() timeout / close / yes — tested at the rx+timeout level in + // isolation (the same logic confirm() delegates to), avoiding the + // send() REST call. Full confirm() round-trips are covered by live + // agent testing with a real (or mock) Telegram bot. + // --------------------------------------------------------------------------- + + #[tokio::test] + async fn confirm_timeout_logic_denies_on_timeout() { + tokio::time::pause(); + let (_tx, mut rx) = mpsc::channel::(1); + let timeout_fut = tokio::time::timeout(crate::CONFIRM_TIMEOUT, rx.recv()); + tokio::time::advance(crate::CONFIRM_TIMEOUT + Duration::from_millis(1)).await; + let result = timeout_fut.await; + assert!(result.is_err(), "expected timeout Err, got recv result"); } - #[test] - fn start_rejects_empty_allowed_users() { - let channel = TelegramChannel::new("test_token".to_string(), Vec::new()); - let result = channel.start(); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ChannelError::Other(_))); + #[tokio::test] + async fn confirm_close_logic_denies_on_channel_close() { + let (tx, mut rx) = mpsc::channel::(1); + drop(tx); + let result = tokio::time::timeout(crate::CONFIRM_TIMEOUT, rx.recv()).await; + assert!(result.is_ok(), "should not time out"); + assert!( + result.unwrap().is_none(), + "closed channel should yield None" + ); + } + + #[tokio::test] + async fn confirm_yes_logic_accepts_yes_response() { + let (tx, mut rx) = mpsc::channel::(1); + tx.send(plain_message("yes")).await.unwrap(); + let result = tokio::time::timeout(crate::CONFIRM_TIMEOUT, rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(result.text.trim().eq_ignore_ascii_case("yes")); + } + + #[tokio::test] + async fn confirm_no_logic_denies_non_yes_response() { + let (tx, mut rx) = mpsc::channel::(1); + tx.send(plain_message("no")).await.unwrap(); + let result = tokio::time::timeout(crate::CONFIRM_TIMEOUT, rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(!result.text.trim().eq_ignore_ascii_case("yes")); } }