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
This commit is contained in:
Chummy 2026-02-16 18:58:24 +08:00 committed by GitHub
parent 79a6f180a8
commit 49fcc7a2c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1156 additions and 0 deletions

View file

@ -1,3 +1,16 @@
pub mod loop_;
pub use loop_::run;
#[cfg(test)]
mod tests {
use super::*;
fn assert_reexport_exists<F>(_value: F) {}
#[test]
fn run_function_is_reexported() {
assert_reexport_exists(run);
assert_reexport_exists(loop_::run);
}
}

View file

@ -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<ChannelMessage>,
) -> 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");
}
}

View file

@ -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");
}
}

View file

@ -114,3 +114,89 @@ fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
.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());
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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"));
}
}

View file

@ -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::<SkillCommands>(&serde_json::to_string(&skill).unwrap()).unwrap(),
skill
);
assert_eq!(
serde_json::from_str::<MigrateCommands>(&serde_json::to_string(&migrate).unwrap())
.unwrap(),
migrate
);
assert_eq!(
serde_json::from_str::<CronCommands>(&serde_json::to_string(&cron).unwrap()).unwrap(),
cron
);
assert_eq!(
serde_json::from_str::<IntegrationCommands>(
&serde_json::to_string(&integration).unwrap()
)
.unwrap(),
integration
);
}
}

View file

@ -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));
}
}

View file

@ -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<u64>,
metrics: Mutex<u64>,
}
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::<DummyObserver>().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(_)));
}
}

View file

@ -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<F>(_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);
}
}

View file

@ -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());
}
}

View file

@ -30,3 +30,74 @@ pub trait RuntimeAdapter: Send + Sync {
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command>;
}
#[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<tokio::process::Command> {
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"));
}
}

View file

@ -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");
}
}

View file

@ -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<ToolResult> {
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"));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}