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:
parent
79a6f180a8
commit
49fcc7a2c4
21 changed files with 1156 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
src/lib.rs
72
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::<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue