From 49fcc7a2c45a3698c005b541ec20bbb450ddc0ff Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 18:58:24 +0800 Subject: [PATCH] test: deepen and complete project-wide test coverage (#297) * test: deepen coverage for health doctor provider and tunnels * test: add broad trait and module re-export coverage --- src/agent/mod.rs | 13 ++++ src/channels/traits.rs | 74 ++++++++++++++++++++ src/config/mod.rs | 42 ++++++++++++ src/doctor/mod.rs | 86 +++++++++++++++++++++++ src/health/mod.rs | 81 ++++++++++++++++++++++ src/heartbeat/mod.rs | 33 +++++++++ src/integrations/mod.rs | 54 +++++++++++++++ src/lib.rs | 72 +++++++++++++++++++ src/memory/traits.rs | 50 ++++++++++++++ src/observability/traits.rs | 69 +++++++++++++++++++ src/onboard/mod.rs | 14 ++++ src/providers/openrouter.rs | 133 ++++++++++++++++++++++++++++++++++++ src/runtime/traits.rs | 71 +++++++++++++++++++ src/security/mod.rs | 25 +++++++ src/tools/traits.rs | 78 +++++++++++++++++++++ src/tunnel/cloudflare.rs | 30 ++++++++ src/tunnel/custom.rs | 75 ++++++++++++++++++++ src/tunnel/mod.rs | 59 ++++++++++++++++ src/tunnel/ngrok.rs | 30 ++++++++ src/tunnel/none.rs | 36 ++++++++++ src/tunnel/tailscale.rs | 31 +++++++++ 21 files changed, 1156 insertions(+) diff --git a/src/agent/mod.rs b/src/agent/mod.rs index f889613..83fd645 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,3 +1,16 @@ pub mod loop_; pub use loop_::run; + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_reexport_exists(_value: F) {} + + #[test] + fn run_function_is_reexported() { + assert_reexport_exists(run); + assert_reexport_exists(loop_::run); + } +} diff --git a/src/channels/traits.rs b/src/channels/traits.rs index ae6239b..59b361e 100644 --- a/src/channels/traits.rs +++ b/src/channels/traits.rs @@ -38,3 +38,77 @@ pub trait Channel: Send + Sync { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyChannel; + + #[async_trait] + impl Channel for DummyChannel { + fn name(&self) -> &str { + "dummy" + } + + async fn send(&self, _message: &str, _recipient: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn listen( + &self, + tx: tokio::sync::mpsc::Sender, + ) -> anyhow::Result<()> { + tx.send(ChannelMessage { + id: "1".into(), + sender: "tester".into(), + content: "hello".into(), + channel: "dummy".into(), + timestamp: 123, + }) + .await + .map_err(|e| anyhow::anyhow!(e.to_string())) + } + } + + #[test] + fn channel_message_clone_preserves_fields() { + let message = ChannelMessage { + id: "42".into(), + sender: "alice".into(), + content: "ping".into(), + channel: "dummy".into(), + timestamp: 999, + }; + + let cloned = message.clone(); + assert_eq!(cloned.id, "42"); + assert_eq!(cloned.sender, "alice"); + assert_eq!(cloned.content, "ping"); + assert_eq!(cloned.channel, "dummy"); + assert_eq!(cloned.timestamp, 999); + } + + #[tokio::test] + async fn default_trait_methods_return_success() { + let channel = DummyChannel; + + assert!(channel.health_check().await); + assert!(channel.start_typing("bob").await.is_ok()); + assert!(channel.stop_typing("bob").await.is_ok()); + assert!(channel.send("hello", "bob").await.is_ok()); + } + + #[tokio::test] + async fn listen_sends_message_to_channel() { + let channel = DummyChannel; + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + channel.listen(tx).await.unwrap(); + + let received = rx.recv().await.expect("message should be sent"); + assert_eq!(received.sender, "tester"); + assert_eq!(received.content, "hello"); + assert_eq!(received.channel, "dummy"); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 1463e32..d8980c0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -9,3 +9,45 @@ pub use schema::{ SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig, }; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reexported_config_default_is_constructible() { + let config = Config::default(); + + assert!(config.default_provider.is_some()); + assert!(config.default_model.is_some()); + assert!(config.default_temperature > 0.0); + } + + #[test] + fn reexported_channel_configs_are_constructible() { + let telegram = TelegramConfig { + bot_token: "token".into(), + allowed_users: vec!["alice".into()], + }; + + let discord = DiscordConfig { + bot_token: "token".into(), + guild_id: Some("123".into()), + allowed_users: vec![], + listen_to_bots: false, + }; + + let lark = LarkConfig { + app_id: "app-id".into(), + app_secret: "app-secret".into(), + encrypt_key: None, + verification_token: None, + allowed_users: vec![], + use_feishu: false, + }; + + assert_eq!(telegram.allowed_users.len(), 1); + assert_eq!(discord.guild_id.as_deref(), Some("123")); + assert_eq!(lark.app_id, "app-id"); + } +} diff --git a/src/doctor/mod.rs b/src/doctor/mod.rs index e858f7c..f4f3b99 100644 --- a/src/doctor/mod.rs +++ b/src/doctor/mod.rs @@ -114,3 +114,89 @@ fn parse_rfc3339(raw: &str) -> Option> { .ok() .map(|dt| dt.with_timezone(&Utc)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use serde_json::json; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + let mut config = Config::default(); + config.workspace_dir = tmp.path().join("workspace"); + config.config_path = tmp.path().join("config.toml"); + config + } + + #[test] + fn parse_rfc3339_accepts_valid_timestamp() { + let parsed = parse_rfc3339("2025-01-02T03:04:05Z"); + assert!(parsed.is_some()); + } + + #[test] + fn parse_rfc3339_rejects_invalid_timestamp() { + let parsed = parse_rfc3339("not-a-timestamp"); + assert!(parsed.is_none()); + } + + #[test] + fn run_returns_ok_when_state_file_missing() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let result = run(&config); + + assert!(result.is_ok()); + } + + #[test] + fn run_returns_error_for_invalid_json_state_file() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let state_file = crate::daemon::state_file_path(&config); + + std::fs::write(&state_file, "not-json").unwrap(); + + let result = run(&config); + + assert!(result.is_err()); + let error_text = result.unwrap_err().to_string(); + assert!(error_text.contains("Failed to parse")); + } + + #[test] + fn run_accepts_well_formed_state_snapshot() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let state_file = crate::daemon::state_file_path(&config); + + let now = Utc::now().to_rfc3339(); + let snapshot = json!({ + "updated_at": now, + "components": { + "scheduler": { + "status": "ok", + "last_ok": now, + "last_error": null, + "updated_at": now, + "restart_count": 0 + }, + "channel:discord": { + "status": "ok", + "last_ok": now, + "last_error": null, + "updated_at": now, + "restart_count": 0 + } + } + }); + + std::fs::write(&state_file, serde_json::to_vec_pretty(&snapshot).unwrap()).unwrap(); + + let result = run(&config); + + assert!(result.is_ok()); + } +} diff --git a/src/health/mod.rs b/src/health/mod.rs index f3f35d8..1d28ef0 100644 --- a/src/health/mod.rs +++ b/src/health/mod.rs @@ -104,3 +104,84 @@ pub fn snapshot_json() -> serde_json::Value { }) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_component(prefix: &str) -> String { + format!("{prefix}-{}", uuid::Uuid::new_v4()) + } + + #[test] + fn mark_component_ok_initializes_component_state() { + let component = unique_component("health-ok"); + + mark_component_ok(&component); + + let snapshot = snapshot(); + let entry = snapshot + .components + .get(&component) + .expect("component should be present after mark_component_ok"); + + assert_eq!(entry.status, "ok"); + assert!(entry.last_ok.is_some()); + assert!(entry.last_error.is_none()); + } + + #[test] + fn mark_component_error_then_ok_clears_last_error() { + let component = unique_component("health-error"); + + mark_component_error(&component, "first failure"); + let error_snapshot = snapshot(); + let errored = error_snapshot + .components + .get(&component) + .expect("component should exist after mark_component_error"); + assert_eq!(errored.status, "error"); + assert_eq!(errored.last_error.as_deref(), Some("first failure")); + + mark_component_ok(&component); + let recovered_snapshot = snapshot(); + let recovered = recovered_snapshot + .components + .get(&component) + .expect("component should exist after recovery"); + assert_eq!(recovered.status, "ok"); + assert!(recovered.last_error.is_none()); + assert!(recovered.last_ok.is_some()); + } + + #[test] + fn bump_component_restart_increments_counter() { + let component = unique_component("health-restart"); + + bump_component_restart(&component); + bump_component_restart(&component); + + let snapshot = snapshot(); + let entry = snapshot + .components + .get(&component) + .expect("component should exist after restart bump"); + + assert_eq!(entry.restart_count, 2); + } + + #[test] + fn snapshot_json_contains_registered_component_fields() { + let component = unique_component("health-json"); + + mark_component_ok(&component); + + let json = snapshot_json(); + let component_json = &json["components"][&component]; + + assert_eq!(component_json["status"], "ok"); + assert!(component_json["updated_at"].as_str().is_some()); + assert!(component_json["last_ok"].as_str().is_some()); + assert!(json["uptime_seconds"].as_u64().is_some()); + } +} diff --git a/src/heartbeat/mod.rs b/src/heartbeat/mod.rs index 702e611..865c91e 100644 --- a/src/heartbeat/mod.rs +++ b/src/heartbeat/mod.rs @@ -1 +1,34 @@ pub mod engine; + +#[cfg(test)] +mod tests { + use crate::config::HeartbeatConfig; + use crate::heartbeat::engine::HeartbeatEngine; + use crate::observability::NoopObserver; + use std::sync::Arc; + + #[test] + fn heartbeat_engine_is_constructible_via_module_export() { + let temp = tempfile::tempdir().unwrap(); + let engine = HeartbeatEngine::new( + HeartbeatConfig::default(), + temp.path().to_path_buf(), + Arc::new(NoopObserver), + ); + + let _ = engine; + } + + #[tokio::test] + async fn ensure_heartbeat_file_creates_expected_file() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path(); + + HeartbeatEngine::ensure_heartbeat_file(workspace) + .await + .unwrap(); + + let heartbeat_path = workspace.join("HEARTBEAT.md"); + assert!(heartbeat_path.exists()); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index d96d668..5be6ddd 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -171,3 +171,57 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> { println!(); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integration_category_all_includes_every_variant_once() { + let all = IntegrationCategory::all(); + assert_eq!(all.len(), 9); + + let labels: Vec<&str> = all.iter().map(|cat| cat.label()).collect(); + assert!(labels.contains(&"Chat Providers")); + assert!(labels.contains(&"AI Models")); + assert!(labels.contains(&"Productivity")); + assert!(labels.contains(&"Music & Audio")); + assert!(labels.contains(&"Smart Home")); + assert!(labels.contains(&"Tools & Automation")); + assert!(labels.contains(&"Media & Creative")); + assert!(labels.contains(&"Social")); + assert!(labels.contains(&"Platforms")); + } + + #[test] + fn handle_command_info_is_case_insensitive_for_known_integrations() { + let config = Config::default(); + let first_name = registry::all_integrations() + .first() + .expect("registry should define at least one integration") + .name + .to_lowercase(); + + let result = handle_command( + crate::IntegrationCommands::Info { name: first_name }, + &config, + ); + + assert!(result.is_ok()); + } + + #[test] + fn handle_command_info_returns_error_for_unknown_integration() { + let config = Config::default(); + let result = handle_command( + crate::IntegrationCommands::Info { + name: "definitely-not-a-real-integration".into(), + }, + &config, + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Unknown integration")); + } +} diff --git a/src/lib.rs b/src/lib.rs index cbb2079..619190b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,3 +163,75 @@ pub enum IntegrationCommands { name: String, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_commands_serde_roundtrip() { + let command = ServiceCommands::Status; + let json = serde_json::to_string(&command).unwrap(); + let parsed: ServiceCommands = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ServiceCommands::Status); + } + + #[test] + fn channel_commands_struct_variants_roundtrip() { + let add = ChannelCommands::Add { + channel_type: "telegram".into(), + config: "{}".into(), + }; + let remove = ChannelCommands::Remove { + name: "main".into(), + }; + + let add_json = serde_json::to_string(&add).unwrap(); + let remove_json = serde_json::to_string(&remove).unwrap(); + + let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap(); + let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap(); + + assert_eq!(parsed_add, add); + assert_eq!(parsed_remove, remove); + } + + #[test] + fn commands_with_payloads_roundtrip() { + let skill = SkillCommands::Install { + source: "https://example.com/skill".into(), + }; + let migrate = MigrateCommands::Openclaw { + source: Some(std::path::PathBuf::from("/tmp/openclaw")), + dry_run: true, + }; + let cron = CronCommands::Add { + expression: "*/5 * * * *".into(), + command: "echo hi".into(), + }; + let integration = IntegrationCommands::Info { + name: "Telegram".into(), + }; + + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&skill).unwrap()).unwrap(), + skill + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&migrate).unwrap()) + .unwrap(), + migrate + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&cron).unwrap()).unwrap(), + cron + ); + assert_eq!( + serde_json::from_str::( + &serde_json::to_string(&integration).unwrap() + ) + .unwrap(), + integration + ); + } +} diff --git a/src/memory/traits.rs b/src/memory/traits.rs index 16d8fa6..72e120e 100644 --- a/src/memory/traits.rs +++ b/src/memory/traits.rs @@ -66,3 +66,53 @@ pub trait Memory: Send + Sync { /// Health check async fn health_check(&self) -> bool; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_category_display_outputs_expected_values() { + assert_eq!(MemoryCategory::Core.to_string(), "core"); + assert_eq!(MemoryCategory::Daily.to_string(), "daily"); + assert_eq!(MemoryCategory::Conversation.to_string(), "conversation"); + assert_eq!( + MemoryCategory::Custom("project_notes".into()).to_string(), + "project_notes" + ); + } + + #[test] + fn memory_category_serde_uses_snake_case() { + let core = serde_json::to_string(&MemoryCategory::Core).unwrap(); + let daily = serde_json::to_string(&MemoryCategory::Daily).unwrap(); + let conversation = serde_json::to_string(&MemoryCategory::Conversation).unwrap(); + + assert_eq!(core, "\"core\""); + assert_eq!(daily, "\"daily\""); + assert_eq!(conversation, "\"conversation\""); + } + + #[test] + fn memory_entry_roundtrip_preserves_optional_fields() { + let entry = MemoryEntry { + id: "id-1".into(), + key: "favorite_language".into(), + content: "Rust".into(), + category: MemoryCategory::Core, + timestamp: "2026-02-16T00:00:00Z".into(), + session_id: Some("session-abc".into()), + score: Some(0.98), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: MemoryEntry = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.id, "id-1"); + assert_eq!(parsed.key, "favorite_language"); + assert_eq!(parsed.content, "Rust"); + assert_eq!(parsed.category, MemoryCategory::Core); + assert_eq!(parsed.session_id.as_deref(), Some("session-abc")); + assert_eq!(parsed.score, Some(0.98)); + } +} diff --git a/src/observability/traits.rs b/src/observability/traits.rs index 08ac2ea..b5b05f3 100644 --- a/src/observability/traits.rs +++ b/src/observability/traits.rs @@ -58,3 +58,72 @@ pub trait Observer: Send + Sync + 'static { self } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Default)] + struct DummyObserver { + events: Mutex, + metrics: Mutex, + } + + impl Observer for DummyObserver { + fn record_event(&self, _event: &ObserverEvent) { + let mut guard = self.events.lock().unwrap(); + *guard += 1; + } + + fn record_metric(&self, _metric: &ObserverMetric) { + let mut guard = self.metrics.lock().unwrap(); + *guard += 1; + } + + fn name(&self) -> &str { + "dummy-observer" + } + } + + #[test] + fn observer_records_events_and_metrics() { + let observer = DummyObserver::default(); + + observer.record_event(&ObserverEvent::HeartbeatTick); + observer.record_event(&ObserverEvent::Error { + component: "test".into(), + message: "boom".into(), + }); + observer.record_metric(&ObserverMetric::TokensUsed(42)); + + assert_eq!(*observer.events.lock().unwrap(), 2); + assert_eq!(*observer.metrics.lock().unwrap(), 1); + } + + #[test] + fn observer_default_flush_and_as_any_work() { + let observer = DummyObserver::default(); + + observer.flush(); + assert_eq!(observer.name(), "dummy-observer"); + assert!(observer.as_any().downcast_ref::().is_some()); + } + + #[test] + fn observer_event_and_metric_are_cloneable() { + let event = ObserverEvent::ToolCall { + tool: "shell".into(), + duration: Duration::from_millis(10), + success: true, + }; + let metric = ObserverMetric::RequestLatency(Duration::from_millis(8)); + + let cloned_event = event.clone(); + let cloned_metric = metric.clone(); + + assert!(matches!(cloned_event, ObserverEvent::ToolCall { .. })); + assert!(matches!(cloned_metric, ObserverMetric::RequestLatency(_))); + } +} diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs index a18ce8a..c3658bd 100644 --- a/src/onboard/mod.rs +++ b/src/onboard/mod.rs @@ -1,3 +1,17 @@ pub mod wizard; pub use wizard::{run_channels_repair_wizard, run_quick_setup, run_wizard}; + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_reexport_exists(_value: F) {} + + #[test] + fn wizard_functions_are_reexported() { + assert_reexport_exists(run_wizard); + assert_reexport_exists(run_channels_repair_wizard); + assert_reexport_exists(run_quick_setup); + } +} diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index 51aefcc..6cb90e3 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -172,3 +172,136 @@ impl Provider for OpenRouterProvider { .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::traits::{ChatMessage, Provider}; + + #[test] + fn creates_with_key() { + let provider = OpenRouterProvider::new(Some("sk-or-123")); + assert_eq!(provider.api_key.as_deref(), Some("sk-or-123")); + } + + #[test] + fn creates_without_key() { + let provider = OpenRouterProvider::new(None); + assert!(provider.api_key.is_none()); + } + + #[tokio::test] + async fn warmup_without_key_is_noop() { + let provider = OpenRouterProvider::new(None); + let result = provider.warmup().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn chat_with_system_fails_without_key() { + let provider = OpenRouterProvider::new(None); + let result = provider + .chat_with_system(Some("system"), "hello", "openai/gpt-4o", 0.2) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("API key not set")); + } + + #[tokio::test] + async fn chat_with_history_fails_without_key() { + let provider = OpenRouterProvider::new(None); + let messages = vec![ + ChatMessage { + role: "system".into(), + content: "be concise".into(), + }, + ChatMessage { + role: "user".into(), + content: "hello".into(), + }, + ]; + + let result = provider + .chat_with_history(&messages, "anthropic/claude-sonnet-4", 0.7) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("API key not set")); + } + + #[test] + fn chat_request_serializes_with_system_and_user() { + let request = ChatRequest { + model: "anthropic/claude-sonnet-4".into(), + messages: vec![ + Message { + role: "system".into(), + content: "You are helpful".into(), + }, + Message { + role: "user".into(), + content: "Summarize this".into(), + }, + ], + temperature: 0.5, + }; + + let json = serde_json::to_string(&request).unwrap(); + + assert!(json.contains("anthropic/claude-sonnet-4")); + assert!(json.contains("\"role\":\"system\"")); + assert!(json.contains("\"role\":\"user\"")); + assert!(json.contains("\"temperature\":0.5")); + } + + #[test] + fn chat_request_serializes_history_messages() { + let messages = [ + ChatMessage { + role: "assistant".into(), + content: "Previous answer".into(), + }, + ChatMessage { + role: "user".into(), + content: "Follow-up".into(), + }, + ]; + + let request = ChatRequest { + model: "google/gemini-2.5-pro".into(), + messages: messages + .iter() + .map(|msg| Message { + role: msg.role.clone(), + content: msg.content.clone(), + }) + .collect(), + temperature: 0.0, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"role\":\"assistant\"")); + assert!(json.contains("\"role\":\"user\"")); + assert!(json.contains("google/gemini-2.5-pro")); + } + + #[test] + fn response_deserializes_single_choice() { + let json = r#"{"choices":[{"message":{"content":"Hi from OpenRouter"}}]}"#; + + let response: ApiChatResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(response.choices.len(), 1); + assert_eq!(response.choices[0].message.content, "Hi from OpenRouter"); + } + + #[test] + fn response_deserializes_empty_choices() { + let json = r#"{"choices":[]}"#; + + let response: ApiChatResponse = serde_json::from_str(json).unwrap(); + + assert!(response.choices.is_empty()); + } +} diff --git a/src/runtime/traits.rs b/src/runtime/traits.rs index 743ee5e..153c06f 100644 --- a/src/runtime/traits.rs +++ b/src/runtime/traits.rs @@ -30,3 +30,74 @@ pub trait RuntimeAdapter: Send + Sync { workspace_dir: &Path, ) -> anyhow::Result; } + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyRuntime; + + impl RuntimeAdapter for DummyRuntime { + fn name(&self) -> &str { + "dummy-runtime" + } + + fn has_shell_access(&self) -> bool { + true + } + + fn has_filesystem_access(&self) -> bool { + true + } + + fn storage_path(&self) -> PathBuf { + PathBuf::from("/tmp/dummy-runtime") + } + + fn supports_long_running(&self) -> bool { + true + } + + fn build_shell_command( + &self, + command: &str, + workspace_dir: &Path, + ) -> anyhow::Result { + let mut cmd = tokio::process::Command::new("echo"); + cmd.arg(command); + cmd.current_dir(workspace_dir); + Ok(cmd) + } + } + + #[test] + fn default_memory_budget_is_zero() { + let runtime = DummyRuntime; + assert_eq!(runtime.memory_budget(), 0); + } + + #[test] + fn runtime_reports_capabilities() { + let runtime = DummyRuntime; + + assert_eq!(runtime.name(), "dummy-runtime"); + assert!(runtime.has_shell_access()); + assert!(runtime.has_filesystem_access()); + assert!(runtime.supports_long_running()); + assert_eq!(runtime.storage_path(), PathBuf::from("/tmp/dummy-runtime")); + } + + #[tokio::test] + async fn build_shell_command_executes() { + let runtime = DummyRuntime; + let mut cmd = runtime + .build_shell_command("hello-runtime", Path::new(".")) + .unwrap(); + + let output = cmd.output().await.unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(output.status.success()); + assert!(stdout.contains("hello-runtime")); + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs index 498fd18..4009b6f 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -23,3 +23,28 @@ pub use policy::{AutonomyLevel, SecurityPolicy}; pub use secrets::SecretStore; #[allow(unused_imports)] pub use traits::{NoopSandbox, Sandbox}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reexported_policy_and_pairing_types_are_usable() { + let policy = SecurityPolicy::default(); + assert_eq!(policy.autonomy, AutonomyLevel::Supervised); + + let guard = PairingGuard::new(false, &[]); + assert!(!guard.require_pairing()); + } + + #[test] + fn reexported_secret_store_encrypt_decrypt_roundtrip() { + let temp = tempfile::tempdir().unwrap(); + let store = SecretStore::new(temp.path(), false); + + let encrypted = store.encrypt("top-secret").unwrap(); + let decrypted = store.decrypt(&encrypted).unwrap(); + + assert_eq!(decrypted, "top-secret"); + } +} diff --git a/src/tools/traits.rs b/src/tools/traits.rs index 714e83b..0a12606 100644 --- a/src/tools/traits.rs +++ b/src/tools/traits.rs @@ -41,3 +41,81 @@ pub trait Tool: Send + Sync { } } } + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyTool; + + #[async_trait] + impl Tool for DummyTool { + fn name(&self) -> &str { + "dummy_tool" + } + + fn description(&self) -> &str { + "A deterministic test tool" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "value": { "type": "string" } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult { + success: true, + output: args + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + error: None, + }) + } + } + + #[test] + fn spec_uses_tool_metadata_and_schema() { + let tool = DummyTool; + let spec = tool.spec(); + + assert_eq!(spec.name, "dummy_tool"); + assert_eq!(spec.description, "A deterministic test tool"); + assert_eq!(spec.parameters["type"], "object"); + assert_eq!(spec.parameters["properties"]["value"]["type"], "string"); + } + + #[tokio::test] + async fn execute_returns_expected_output() { + let tool = DummyTool; + let result = tool + .execute(serde_json::json!({ "value": "hello-tool" })) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "hello-tool"); + assert!(result.error.is_none()); + } + + #[test] + fn tool_result_serialization_roundtrip() { + let result = ToolResult { + success: false, + output: String::new(), + error: Some("boom".into()), + }; + + let json = serde_json::to_string(&result).unwrap(); + let parsed: ToolResult = serde_json::from_str(&json).unwrap(); + + assert!(!parsed.success); + assert_eq!(parsed.error.as_deref(), Some("boom")); + } +} diff --git a/src/tunnel/cloudflare.rs b/src/tunnel/cloudflare.rs index e387099..d92cbb7 100644 --- a/src/tunnel/cloudflare.rs +++ b/src/tunnel/cloudflare.rs @@ -109,3 +109,33 @@ impl Tunnel for CloudflareTunnel { .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructor_stores_token() { + let tunnel = CloudflareTunnel::new("cf-token".into()); + assert_eq!(tunnel.token, "cf-token"); + } + + #[test] + fn public_url_is_none_before_start() { + let tunnel = CloudflareTunnel::new("cf-token".into()); + assert!(tunnel.public_url().is_none()); + } + + #[tokio::test] + async fn stop_without_started_process_is_ok() { + let tunnel = CloudflareTunnel::new("cf-token".into()); + let result = tunnel.stop().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn health_check_is_false_before_start() { + let tunnel = CloudflareTunnel::new("cf-token".into()); + assert!(!tunnel.health_check().await); + } +} diff --git a/src/tunnel/custom.rs b/src/tunnel/custom.rs index c65ff32..ef962b4 100644 --- a/src/tunnel/custom.rs +++ b/src/tunnel/custom.rs @@ -143,3 +143,78 @@ impl Tunnel for CustomTunnel { .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn start_with_empty_command_returns_error() { + let tunnel = CustomTunnel::new(" ".into(), None, None); + let result = tunnel.start("127.0.0.1", 8080).await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("start_command is empty")); + } + + #[tokio::test] + async fn start_without_pattern_returns_local_url() { + let tunnel = CustomTunnel::new("sleep 1".into(), None, None); + + let url = tunnel.start("127.0.0.1", 4455).await.unwrap(); + assert_eq!(url, "http://127.0.0.1:4455"); + assert_eq!( + tunnel.public_url().as_deref(), + Some("http://127.0.0.1:4455") + ); + + tunnel.stop().await.unwrap(); + } + + #[tokio::test] + async fn start_with_pattern_extracts_url() { + let tunnel = CustomTunnel::new( + "echo https://public.example".into(), + None, + Some("public.example".into()), + ); + + let url = tunnel.start("localhost", 9999).await.unwrap(); + + assert_eq!(url, "https://public.example"); + assert_eq!( + tunnel.public_url().as_deref(), + Some("https://public.example") + ); + + tunnel.stop().await.unwrap(); + } + + #[tokio::test] + async fn start_replaces_host_and_port_placeholders() { + let tunnel = CustomTunnel::new( + "echo http://{host}:{port}".into(), + None, + Some("http://".into()), + ); + + let url = tunnel.start("10.1.2.3", 4321).await.unwrap(); + + assert_eq!(url, "http://10.1.2.3:4321"); + tunnel.stop().await.unwrap(); + } + + #[tokio::test] + async fn health_check_with_unreachable_health_url_returns_false() { + let tunnel = CustomTunnel::new( + "sleep 1".into(), + Some("http://127.0.0.1:9/healthz".into()), + None, + ); + + assert!(!tunnel.health_check().await); + } +} diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index 0682a1b..6a852d8 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -128,6 +128,7 @@ mod tests { use crate::config::schema::{ CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TunnelConfig, }; + use tokio::process::Command; /// Helper: assert `create_tunnel` returns an error containing `needle`. fn assert_tunnel_err(cfg: &TunnelConfig, needle: &str) { @@ -313,4 +314,62 @@ mod tests { assert_eq!(t.name(), "custom"); assert!(t.public_url().is_none()); } + + #[tokio::test] + async fn kill_shared_no_process_is_ok() { + let proc = new_shared_process(); + let result = kill_shared(&proc).await; + + assert!(result.is_ok()); + assert!(proc.lock().await.is_none()); + } + + #[tokio::test] + async fn kill_shared_terminates_and_clears_child() { + let proc = new_shared_process(); + + let child = Command::new("sleep") + .arg("30") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("sleep should spawn for lifecycle test"); + + { + let mut guard = proc.lock().await; + *guard = Some(TunnelProcess { + child, + public_url: "https://example.test".into(), + }); + } + + kill_shared(&proc).await.unwrap(); + + let guard = proc.lock().await; + assert!(guard.is_none()); + } + + #[tokio::test] + async fn cloudflare_health_false_before_start() { + let tunnel = CloudflareTunnel::new("tok".into()); + assert!(!tunnel.health_check().await); + } + + #[tokio::test] + async fn ngrok_health_false_before_start() { + let tunnel = NgrokTunnel::new("tok".into(), None); + assert!(!tunnel.health_check().await); + } + + #[tokio::test] + async fn tailscale_health_false_before_start() { + let tunnel = TailscaleTunnel::new(false, None); + assert!(!tunnel.health_check().await); + } + + #[tokio::test] + async fn custom_health_false_before_start_without_health_url() { + let tunnel = CustomTunnel::new("echo hi".into(), None, Some("https://".into())); + assert!(!tunnel.health_check().await); + } } diff --git a/src/tunnel/ngrok.rs b/src/tunnel/ngrok.rs index e993e79..7d16a11 100644 --- a/src/tunnel/ngrok.rs +++ b/src/tunnel/ngrok.rs @@ -119,3 +119,33 @@ impl Tunnel for NgrokTunnel { .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructor_stores_domain() { + let tunnel = NgrokTunnel::new("ngrok-token".into(), Some("my.ngrok.app".into())); + assert_eq!(tunnel.domain.as_deref(), Some("my.ngrok.app")); + } + + #[test] + fn public_url_is_none_before_start() { + let tunnel = NgrokTunnel::new("ngrok-token".into(), None); + assert!(tunnel.public_url().is_none()); + } + + #[tokio::test] + async fn stop_without_started_process_is_ok() { + let tunnel = NgrokTunnel::new("ngrok-token".into(), None); + let result = tunnel.stop().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn health_check_is_false_before_start() { + let tunnel = NgrokTunnel::new("ngrok-token".into(), None); + assert!(!tunnel.health_check().await); + } +} diff --git a/src/tunnel/none.rs b/src/tunnel/none.rs index a8de838..dc7189a 100644 --- a/src/tunnel/none.rs +++ b/src/tunnel/none.rs @@ -26,3 +26,39 @@ impl Tunnel for NoneTunnel { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_is_none() { + let tunnel = NoneTunnel; + assert_eq!(tunnel.name(), "none"); + } + + #[tokio::test] + async fn start_returns_local_url() { + let tunnel = NoneTunnel; + let url = tunnel.start("127.0.0.1", 7788).await.unwrap(); + assert_eq!(url, "http://127.0.0.1:7788"); + } + + #[tokio::test] + async fn stop_is_noop_success() { + let tunnel = NoneTunnel; + assert!(tunnel.stop().await.is_ok()); + } + + #[tokio::test] + async fn health_check_is_always_true() { + let tunnel = NoneTunnel; + assert!(tunnel.health_check().await); + } + + #[test] + fn public_url_is_always_none() { + let tunnel = NoneTunnel; + assert!(tunnel.public_url().is_none()); + } +} diff --git a/src/tunnel/tailscale.rs b/src/tunnel/tailscale.rs index 4a69038..f983d8e 100644 --- a/src/tunnel/tailscale.rs +++ b/src/tunnel/tailscale.rs @@ -100,3 +100,34 @@ impl Tunnel for TailscaleTunnel { .and_then(|g| g.as_ref().map(|tp| tp.public_url.clone())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructor_stores_hostname_and_mode() { + let tunnel = TailscaleTunnel::new(true, Some("myhost.tailnet.ts.net".into())); + assert!(tunnel.funnel); + assert_eq!(tunnel.hostname.as_deref(), Some("myhost.tailnet.ts.net")); + } + + #[test] + fn public_url_is_none_before_start() { + let tunnel = TailscaleTunnel::new(false, None); + assert!(tunnel.public_url().is_none()); + } + + #[tokio::test] + async fn health_check_is_false_before_start() { + let tunnel = TailscaleTunnel::new(false, None); + assert!(!tunnel.health_check().await); + } + + #[tokio::test] + async fn stop_without_started_process_is_ok() { + let tunnel = TailscaleTunnel::new(false, None); + let result = tunnel.stop().await; + assert!(result.is_ok()); + } +}