fix(tests): replace hardcoded /tmp paths with tempdir + add 300 unit tests (#659)

* test: add unit tests across 20 modules for coverage push

Add 300+ unit tests covering config, context, evaluation, extensions,
LLM, secrets, tools/builder, and tools/mcp modules. All tests are
pure unit tests (no mocks) exercising serde roundtrips, edge cases,
error paths, and business logic.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): replace hardcoded /tmp paths with tempfile::tempdir

The e2e_metrics_test::test_metrics_collected_from_tool_trace test was
failing because setup_test_dir() created /tmp/ironclaw_metrics_test but
the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch).

Added LlmTrace::replace_paths() to substitute fixture paths at runtime,
then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to
tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no
debris on disk.

Regression test: test_metrics_collected_from_tool_trace now passes
consistently regardless of prior /tmp state.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-07 08:24:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8fbb782090
commit cf96a3253c
33 changed files with 5904 additions and 119 deletions
+159
View File
@@ -204,3 +204,162 @@ impl ChannelsConfig {
fn default_channels_dir() -> PathBuf {
ironclaw_base_dir().join("channels")
}
#[cfg(test)]
mod tests {
use crate::config::channels::*;
#[test]
fn cli_config_fields() {
let cfg = CliConfig { enabled: true };
assert!(cfg.enabled);
let disabled = CliConfig { enabled: false };
assert!(!disabled.enabled);
}
#[test]
fn http_config_fields() {
let cfg = HttpConfig {
host: "0.0.0.0".to_string(),
port: 8080,
webhook_secret: None,
user_id: "http".to_string(),
};
assert_eq!(cfg.host, "0.0.0.0");
assert_eq!(cfg.port, 8080);
assert!(cfg.webhook_secret.is_none());
assert_eq!(cfg.user_id, "http");
}
#[test]
fn http_config_with_secret() {
let cfg = HttpConfig {
host: "127.0.0.1".to_string(),
port: 9090,
webhook_secret: Some(secrecy::SecretString::from("s3cret".to_string())),
user_id: "webhook-bot".to_string(),
};
assert!(cfg.webhook_secret.is_some());
assert_eq!(cfg.port, 9090);
}
#[test]
fn gateway_config_fields() {
let cfg = GatewayConfig {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
};
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
}
#[test]
fn gateway_config_no_auth_token() {
let cfg = GatewayConfig {
host: "0.0.0.0".to_string(),
port: 3001,
auth_token: None,
user_id: "anon".to_string(),
};
assert!(cfg.auth_token.is_none());
}
#[test]
fn signal_config_fields_and_defaults() {
let cfg = SignalConfig {
http_url: "http://127.0.0.1:8080".to_string(),
account: "+1234567890".to_string(),
allow_from: vec!["+1234567890".to_string()],
allow_from_groups: vec![],
dm_policy: "pairing".to_string(),
group_policy: "allowlist".to_string(),
group_allow_from: vec![],
ignore_attachments: false,
ignore_stories: true,
};
assert_eq!(cfg.http_url, "http://127.0.0.1:8080");
assert_eq!(cfg.account, "+1234567890");
assert_eq!(cfg.allow_from, vec!["+1234567890"]);
assert!(cfg.allow_from_groups.is_empty());
assert_eq!(cfg.dm_policy, "pairing");
assert_eq!(cfg.group_policy, "allowlist");
assert!(cfg.group_allow_from.is_empty());
assert!(!cfg.ignore_attachments);
assert!(cfg.ignore_stories);
}
#[test]
fn signal_config_open_policies() {
let cfg = SignalConfig {
http_url: "http://localhost:7583".to_string(),
account: "+0000000000".to_string(),
allow_from: vec!["*".to_string()],
allow_from_groups: vec!["*".to_string()],
dm_policy: "open".to_string(),
group_policy: "open".to_string(),
group_allow_from: vec![],
ignore_attachments: true,
ignore_stories: false,
};
assert_eq!(cfg.allow_from, vec!["*"]);
assert_eq!(cfg.allow_from_groups, vec!["*"]);
assert_eq!(cfg.dm_policy, "open");
assert_eq!(cfg.group_policy, "open");
assert!(cfg.ignore_attachments);
assert!(!cfg.ignore_stories);
}
#[test]
fn channels_config_fields() {
let cfg = ChannelsConfig {
cli: CliConfig { enabled: true },
http: None,
gateway: None,
signal: None,
wasm_channels_dir: PathBuf::from("/tmp/channels"),
wasm_channels_enabled: true,
wasm_channel_owner_ids: HashMap::new(),
};
assert!(cfg.cli.enabled);
assert!(cfg.http.is_none());
assert!(cfg.gateway.is_none());
assert!(cfg.signal.is_none());
assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels"));
assert!(cfg.wasm_channels_enabled);
assert!(cfg.wasm_channel_owner_ids.is_empty());
}
#[test]
fn channels_config_with_owner_ids() {
let mut ids = HashMap::new();
ids.insert("telegram".to_string(), 12345_i64);
ids.insert("slack".to_string(), 67890_i64);
let cfg = ChannelsConfig {
cli: CliConfig { enabled: false },
http: None,
gateway: None,
signal: None,
wasm_channels_dir: PathBuf::from("/opt/channels"),
wasm_channels_enabled: false,
wasm_channel_owner_ids: ids,
};
assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345));
assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890));
assert!(!cfg.wasm_channels_enabled);
}
#[test]
fn default_channels_dir_ends_with_channels() {
let dir = default_channels_dir();
assert!(
dir.ends_with("channels"),
"expected path ending in 'channels', got: {dir:?}"
);
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ impl LlmConfig {
backend: "nearai".to_string(),
session: SessionConfig {
auth_base_url: "http://localhost:0".to_string(),
session_path: PathBuf::from("/tmp/ironclaw-test-session.json"),
session_path: std::env::temp_dir().join("ironclaw-test-session.json"),
},
nearai: NearAiConfig {
model: "test-model".to_string(),
+1 -1
View File
@@ -108,7 +108,7 @@ impl Config {
http: None,
gateway: None,
signal: None,
wasm_channels_dir: std::path::PathBuf::from("/tmp/ironclaw-test-channels"),
wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"),
wasm_channels_enabled: false,
wasm_channel_owner_ids: HashMap::new(),
},
+201
View File
@@ -237,3 +237,204 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
.as_str()
.map(String::from)
}
#[cfg(test)]
mod tests {
use crate::config::sandbox::*;
// ── SandboxModeConfig defaults ──────────────────────────────────
#[test]
fn sandbox_mode_config_default_values() {
let cfg = SandboxModeConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.policy, "readonly");
assert_eq!(cfg.timeout_secs, 120);
assert_eq!(cfg.memory_limit_mb, 2048);
assert_eq!(cfg.cpu_shares, 1024);
assert_eq!(cfg.image, "ironclaw-worker:latest");
assert!(cfg.auto_pull_image);
assert!(cfg.extra_allowed_domains.is_empty());
}
#[test]
fn sandbox_mode_config_custom_values() {
let cfg = SandboxModeConfig {
enabled: false,
policy: "full_access".to_string(),
timeout_secs: 600,
memory_limit_mb: 4096,
cpu_shares: 512,
image: "custom-worker:v2".to_string(),
auto_pull_image: false,
extra_allowed_domains: vec!["example.com".to_string()],
};
assert!(!cfg.enabled);
assert_eq!(cfg.policy, "full_access");
assert_eq!(cfg.timeout_secs, 600);
assert_eq!(cfg.memory_limit_mb, 4096);
assert_eq!(cfg.cpu_shares, 512);
assert_eq!(cfg.image, "custom-worker:v2");
assert!(!cfg.auto_pull_image);
assert_eq!(cfg.extra_allowed_domains, vec!["example.com"]);
}
#[test]
fn sandbox_mode_to_sandbox_config_propagates_fields() {
let mode = SandboxModeConfig {
enabled: true,
policy: "workspace_write".to_string(),
timeout_secs: 300,
memory_limit_mb: 1024,
cpu_shares: 2048,
image: "test:latest".to_string(),
auto_pull_image: false,
extra_allowed_domains: vec!["custom.example.com".to_string()],
};
let sc = mode.to_sandbox_config();
assert!(sc.enabled);
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::WorkspaceWrite);
assert_eq!(sc.timeout, std::time::Duration::from_secs(300));
assert_eq!(sc.memory_limit_mb, 1024);
assert_eq!(sc.cpu_shares, 2048);
assert_eq!(sc.image, "test:latest");
assert!(!sc.auto_pull_image);
// extra domain should be in the allowlist
assert!(
sc.network_allowlist
.contains(&"custom.example.com".to_string()),
"expected custom domain in allowlist"
);
}
#[test]
fn sandbox_mode_to_sandbox_config_invalid_policy_falls_back_to_readonly() {
let mode = SandboxModeConfig {
policy: "garbage_value".to_string(),
..SandboxModeConfig::default()
};
let sc = mode.to_sandbox_config();
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::ReadOnly);
}
#[test]
fn sandbox_mode_to_sandbox_config_includes_default_allowlist() {
let mode = SandboxModeConfig::default();
let sc = mode.to_sandbox_config();
// The default allowlist from sandbox module should be non-empty
assert!(
!sc.network_allowlist.is_empty(),
"default allowlist should not be empty"
);
}
// ── ClaudeCodeConfig defaults ───────────────────────────────────
#[test]
fn claude_code_config_default_values() {
let cfg = ClaudeCodeConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.model, "sonnet");
assert_eq!(cfg.max_turns, 50);
assert_eq!(cfg.memory_limit_mb, 4096);
assert!(cfg.config_dir.ends_with(".claude"));
// Should have all the standard tools
assert!(!cfg.allowed_tools.is_empty());
assert!(cfg.allowed_tools.contains(&"Bash(*)".to_string()));
assert!(cfg.allowed_tools.contains(&"Read(*)".to_string()));
assert!(cfg.allowed_tools.contains(&"Edit(*)".to_string()));
assert!(cfg.allowed_tools.contains(&"Write(*)".to_string()));
assert!(cfg.allowed_tools.contains(&"Grep(*)".to_string()));
assert!(cfg.allowed_tools.contains(&"WebFetch(*)".to_string()));
}
#[test]
fn claude_code_config_custom_values() {
let cfg = ClaudeCodeConfig {
enabled: true,
config_dir: std::path::PathBuf::from("/opt/claude"),
model: "opus".to_string(),
max_turns: 100,
memory_limit_mb: 8192,
allowed_tools: vec!["Read(*)".to_string(), "Bash(*)".to_string()],
};
assert!(cfg.enabled);
assert_eq!(cfg.config_dir, std::path::PathBuf::from("/opt/claude"));
assert_eq!(cfg.model, "opus");
assert_eq!(cfg.max_turns, 100);
assert_eq!(cfg.memory_limit_mb, 8192);
assert_eq!(cfg.allowed_tools.len(), 2);
}
// ── parse_oauth_access_token ────────────────────────────────────
#[test]
fn parse_oauth_token_valid() {
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
let token = parse_oauth_access_token(json);
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
}
#[test]
fn parse_oauth_token_missing_access_token() {
let json = r#"{"claudeAiOauth": {}}"#;
assert_eq!(parse_oauth_access_token(json), None);
}
#[test]
fn parse_oauth_token_missing_oauth_key() {
let json = r#"{"someOtherKey": {"accessToken": "tok"}}"#;
assert_eq!(parse_oauth_access_token(json), None);
}
#[test]
fn parse_oauth_token_invalid_json() {
assert_eq!(parse_oauth_access_token("not json at all"), None);
}
#[test]
fn parse_oauth_token_empty_string() {
assert_eq!(parse_oauth_access_token(""), None);
}
#[test]
fn parse_oauth_token_nested_extra_fields() {
let json = r#"{
"claudeAiOauth": {
"accessToken": "sk-ant-real-token",
"refreshToken": "rt-abc",
"expiresAt": 1700000000
}
}"#;
assert_eq!(
parse_oauth_access_token(json),
Some("sk-ant-real-token".to_string())
);
}
#[test]
fn parse_oauth_token_access_token_is_not_string() {
let json = r#"{"claudeAiOauth": {"accessToken": 12345}}"#;
assert_eq!(parse_oauth_access_token(json), None);
}
// ── default_claude_code_allowed_tools ───────────────────────────
#[test]
fn default_allowed_tools_has_expected_count() {
let tools = default_claude_code_allowed_tools();
// 10 tools: Read, Write, Edit, Glob, Grep, NotebookEdit, Bash, Task, WebFetch, WebSearch
assert_eq!(tools.len(), 10);
}
#[test]
fn default_allowed_tools_all_have_glob_pattern() {
let tools = default_claude_code_allowed_tools();
for tool in &tools {
assert!(
tool.ends_with("(*)"),
"tool '{tool}' should end with '(*)' glob pattern"
);
}
}
}
+212
View File
@@ -104,3 +104,215 @@ impl TunnelConfig {
})
}
}
#[cfg(test)]
mod tests {
use crate::config::tunnel::TunnelConfig;
use crate::tunnel::{
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
TunnelProviderConfig,
};
// ── Default ─────────────────────────────────────────────────────
#[test]
fn default_is_disabled() {
let cfg = TunnelConfig::default();
assert!(cfg.public_url.is_none());
assert!(cfg.provider.is_none());
assert!(!cfg.is_enabled());
}
// ── is_enabled ──────────────────────────────────────────────────
#[test]
fn is_enabled_with_static_url() {
let cfg = TunnelConfig {
public_url: Some("https://tunnel.example.com".to_string()),
provider: None,
};
assert!(cfg.is_enabled());
}
#[test]
fn is_enabled_with_provider() {
let cfg = TunnelConfig {
public_url: None,
provider: Some(TunnelProviderConfig {
provider: "cloudflare".to_string(),
cloudflare: Some(CloudflareTunnelConfig {
token: "cf-tok".to_string(),
}),
tailscale: None,
ngrok: None,
custom: None,
}),
};
assert!(cfg.is_enabled());
}
#[test]
fn is_enabled_with_both() {
let cfg = TunnelConfig {
public_url: Some("https://example.com".to_string()),
provider: Some(TunnelProviderConfig {
provider: "ngrok".to_string(),
cloudflare: None,
tailscale: None,
ngrok: Some(NgrokTunnelConfig {
auth_token: "ngrok-tok".to_string(),
domain: None,
}),
custom: None,
}),
};
assert!(cfg.is_enabled());
}
// ── webhook_url ─────────────────────────────────────────────────
#[test]
fn webhook_url_none_when_no_public_url() {
let cfg = TunnelConfig::default();
assert!(cfg.webhook_url("/hook").is_none());
}
#[test]
fn webhook_url_basic() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url("/webhook/telegram"),
Some("https://abc.ngrok.io/webhook/telegram".to_string())
);
}
#[test]
fn webhook_url_trims_trailing_slash_on_base() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io/".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url("/hook"),
Some("https://abc.ngrok.io/hook".to_string())
);
}
#[test]
fn webhook_url_trims_leading_slash_on_path() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
// Path without leading slash should also work
assert_eq!(
cfg.webhook_url("hook"),
Some("https://abc.ngrok.io/hook".to_string())
);
}
#[test]
fn webhook_url_double_slash_normalization() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io/".to_string()),
provider: None,
};
// Both base trailing and path leading slashes trimmed
assert_eq!(
cfg.webhook_url("/api/webhook"),
Some("https://abc.ngrok.io/api/webhook".to_string())
);
}
#[test]
fn webhook_url_empty_path() {
let cfg = TunnelConfig {
public_url: Some("https://abc.ngrok.io".to_string()),
provider: None,
};
assert_eq!(
cfg.webhook_url(""),
Some("https://abc.ngrok.io/".to_string())
);
}
// ── TunnelProviderConfig field coverage ─────────────────────────
#[test]
fn provider_config_cloudflare() {
let p = TunnelProviderConfig {
provider: "cloudflare".to_string(),
cloudflare: Some(CloudflareTunnelConfig {
token: "cf-secret".to_string(),
}),
tailscale: None,
ngrok: None,
custom: None,
};
assert_eq!(p.provider, "cloudflare");
assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
}
#[test]
fn provider_config_tailscale() {
let ts = TailscaleTunnelConfig {
funnel: true,
hostname: Some("my-host".to_string()),
};
assert!(ts.funnel);
assert_eq!(ts.hostname.as_deref(), Some("my-host"));
}
#[test]
fn provider_config_tailscale_defaults() {
let ts = TailscaleTunnelConfig::default();
assert!(!ts.funnel);
assert!(ts.hostname.is_none());
}
#[test]
fn provider_config_ngrok() {
let ng = NgrokTunnelConfig {
auth_token: "ng-tok".to_string(),
domain: Some("custom.ngrok.dev".to_string()),
};
assert_eq!(ng.auth_token, "ng-tok");
assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
}
#[test]
fn provider_config_ngrok_defaults() {
let ng = NgrokTunnelConfig::default();
assert!(ng.auth_token.is_empty());
assert!(ng.domain.is_none());
}
#[test]
fn provider_config_custom() {
let c = CustomTunnelConfig {
start_command: "bore local {port}".to_string(),
health_url: Some("http://localhost:8080/health".to_string()),
url_pattern: Some("https://bore.pub".to_string()),
};
assert_eq!(c.start_command, "bore local {port}");
assert!(c.health_url.is_some());
assert!(c.url_pattern.is_some());
}
#[test]
fn provider_config_custom_defaults() {
let c = CustomTunnelConfig::default();
assert!(c.start_command.is_empty());
assert!(c.health_url.is_none());
assert!(c.url_pattern.is_none());
}
#[test]
fn cloudflare_config_defaults() {
let cf = CloudflareTunnelConfig::default();
assert!(cf.token.is_empty());
}
}
+387
View File
@@ -490,4 +490,391 @@ mod tests {
assert_eq!(ctx.state, crate::context::JobState::InProgress);
}
}
#[tokio::test]
async fn get_context_not_found() {
let manager = ContextManager::new(5);
let bogus_id = Uuid::new_v4();
let result = manager.get_context(bogus_id).await;
assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id));
}
#[tokio::test]
async fn update_context_not_found() {
let manager = ContextManager::new(5);
let bogus_id = Uuid::new_v4();
let result = manager.update_context(bogus_id, |_ctx| {}).await;
assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id));
}
#[tokio::test]
async fn remove_job_returns_context_and_memory() {
let manager = ContextManager::new(5);
let job_id = manager.create_job("Removable", "bye bye").await.unwrap();
let (ctx, mem) = manager.remove_job(job_id).await.unwrap();
assert_eq!(ctx.title, "Removable");
assert_eq!(mem.job_id, job_id);
// After removal, get should fail
assert!(matches!(
manager.get_context(job_id).await,
Err(JobError::NotFound { .. })
));
assert!(matches!(
manager.get_memory(job_id).await,
Err(JobError::NotFound { .. })
));
}
#[tokio::test]
async fn remove_job_not_found() {
let manager = ContextManager::new(5);
let result = manager.remove_job(Uuid::new_v4()).await;
assert!(matches!(result, Err(JobError::NotFound { .. })));
}
#[tokio::test]
async fn get_memory_and_update_memory() {
let manager = ContextManager::new(5);
let job_id = manager.create_job("Mem test", "desc").await.unwrap();
// Fresh memory should be empty
let mem = manager.get_memory(job_id).await.unwrap();
assert_eq!(mem.job_id, job_id);
assert!(mem.actions.is_empty());
assert!(mem.conversation.is_empty());
// Update memory by adding a message
manager
.update_memory(job_id, |m| {
m.add_message(crate::llm::ChatMessage::user("hello from test"));
})
.await
.unwrap();
let mem = manager.get_memory(job_id).await.unwrap();
assert_eq!(mem.conversation.len(), 1);
assert_eq!(mem.conversation.messages()[0].content, "hello from test");
}
#[tokio::test]
async fn update_memory_not_found() {
let manager = ContextManager::new(5);
let result = manager.update_memory(Uuid::new_v4(), |_| {}).await;
assert!(matches!(result, Err(JobError::NotFound { .. })));
}
#[tokio::test]
async fn get_memory_not_found() {
let manager = ContextManager::new(5);
let result = manager.get_memory(Uuid::new_v4()).await;
assert!(matches!(result, Err(JobError::NotFound { .. })));
}
#[tokio::test]
async fn find_stuck_jobs_returns_only_stuck() {
let manager = ContextManager::new(10);
let id1 = manager.create_job("Job 1", "desc").await.unwrap();
let id2 = manager.create_job("Job 2", "desc").await.unwrap();
let id3 = manager.create_job("Job 3", "desc").await.unwrap();
// Transition id1 and id2 to InProgress, then mark id2 as stuck
for id in [id1, id2, id3] {
manager
.update_context(id, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
}
manager
.update_context(id2, |ctx| ctx.mark_stuck("timed out"))
.await
.unwrap()
.unwrap();
let stuck = manager.find_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0], id2);
}
#[tokio::test]
async fn active_count_tracks_non_terminal_jobs() {
let manager = ContextManager::new(10);
let id1 = manager.create_job("J1", "d").await.unwrap();
let id2 = manager.create_job("J2", "d").await.unwrap();
// Both pending (active)
assert_eq!(manager.active_count().await, 2);
// Transition id1 through to Failed (terminal)
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::Failed, None)
})
.await
.unwrap()
.unwrap();
// id1 is terminal, id2 still pending
assert_eq!(manager.active_count().await, 1);
// Transition id2 to cancelled
manager
.update_context(id2, |ctx| {
ctx.transition_to(crate::context::JobState::Cancelled, None)
})
.await
.unwrap()
.unwrap();
assert_eq!(manager.active_count().await, 0);
}
#[tokio::test]
async fn active_jobs_for_filters_by_user() {
let manager = ContextManager::new(10);
manager
.create_job_for_user("alice", "A1", "d")
.await
.unwrap();
manager
.create_job_for_user("alice", "A2", "d")
.await
.unwrap();
let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap();
assert_eq!(manager.active_jobs_for("alice").await.len(), 2);
assert_eq!(manager.active_jobs_for("bob").await.len(), 1);
assert_eq!(manager.active_jobs_for("nobody").await.len(), 0);
// Make bob's job terminal
manager
.update_context(bob_id, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
manager
.update_context(bob_id, |ctx| {
ctx.transition_to(crate::context::JobState::Failed, None)
})
.await
.unwrap()
.unwrap();
assert_eq!(manager.active_jobs_for("bob").await.len(), 0);
// But all_jobs_for still shows it
assert_eq!(manager.all_jobs_for("bob").await.len(), 1);
}
#[tokio::test]
async fn summary_counts_states_correctly() {
let manager = ContextManager::new(10);
let id1 = manager.create_job("J1", "d").await.unwrap();
let id2 = manager.create_job("J2", "d").await.unwrap();
let id3 = manager.create_job("J3", "d").await.unwrap();
// id1: Pending -> InProgress -> Completed
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::Completed, None)
})
.await
.unwrap()
.unwrap();
// id2: Pending -> InProgress -> Failed
manager
.update_context(id2, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
manager
.update_context(id2, |ctx| {
ctx.transition_to(crate::context::JobState::Failed, None)
})
.await
.unwrap()
.unwrap();
// id3: stays Pending
let s = manager.summary().await;
assert_eq!(s.total, 3);
assert_eq!(s.pending, 1);
assert_eq!(s.completed, 1);
assert_eq!(s.failed, 1);
assert_eq!(s.in_progress, 0);
assert_eq!(s.stuck, 0);
assert_eq!(s.cancelled, 0);
assert_eq!(s.submitted, 0);
assert_eq!(s.accepted, 0);
// Suppress unused field warning
let _ = id3;
}
#[tokio::test]
async fn summary_for_scopes_to_user() {
let manager = ContextManager::new(10);
manager
.create_job_for_user("alice", "A1", "d")
.await
.unwrap();
let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap();
// Transition bob's job to InProgress
manager
.update_context(bob_id, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
let alice_summary = manager.summary_for("alice").await;
assert_eq!(alice_summary.total, 1);
assert_eq!(alice_summary.pending, 1);
assert_eq!(alice_summary.in_progress, 0);
let bob_summary = manager.summary_for("bob").await;
assert_eq!(bob_summary.total, 1);
assert_eq!(bob_summary.pending, 0);
assert_eq!(bob_summary.in_progress, 1);
let nobody_summary = manager.summary_for("nobody").await;
assert_eq!(nobody_summary.total, 0);
}
#[tokio::test]
async fn default_context_manager_has_max_10() {
let manager = ContextManager::default();
// Create 10 jobs and make them active
for i in 0..10 {
let id = manager
.create_job(format!("Job {i}"), "desc")
.await
.unwrap();
manager
.update_context(id, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
}
// 11th should fail
let result = manager.create_job("overflow", "d").await;
assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 10 })));
}
#[tokio::test]
async fn all_jobs_returns_all_regardless_of_state() {
let manager = ContextManager::new(10);
let id1 = manager.create_job("J1", "d").await.unwrap();
manager.create_job("J2", "d").await.unwrap();
// Make id1 terminal
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
manager
.update_context(id1, |ctx| {
ctx.transition_to(crate::context::JobState::Failed, None)
})
.await
.unwrap()
.unwrap();
// all_jobs includes terminal, active_jobs does not
assert_eq!(manager.all_jobs().await.len(), 2);
assert_eq!(manager.active_jobs().await.len(), 1);
}
#[tokio::test]
async fn create_job_uses_default_user() {
let manager = ContextManager::new(5);
let job_id = manager.create_job("Test", "desc").await.unwrap();
let ctx = manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.user_id, "default");
}
#[tokio::test]
async fn concurrent_remove_and_read() {
let manager = std::sync::Arc::new(ContextManager::new(100));
// Create 20 jobs
let mut job_ids = Vec::new();
for i in 0..20 {
let id = manager
.create_job(format!("Job {i}"), "desc")
.await
.unwrap();
job_ids.push(id);
}
// Concurrently remove the first 10 while reading the last 10
let remove_handles: Vec<_> = job_ids[..10]
.iter()
.map(|&id| {
let mgr = std::sync::Arc::clone(&manager);
tokio::spawn(async move { mgr.remove_job(id).await })
})
.collect();
let read_handles: Vec<_> = job_ids[10..]
.iter()
.map(|&id| {
let mgr = std::sync::Arc::clone(&manager);
tokio::spawn(async move { mgr.get_context(id).await })
})
.collect();
for handle in remove_handles {
handle
.await
.expect("remove task should not panic")
.expect("remove should succeed");
}
for handle in read_handles {
let ctx = handle
.await
.expect("read task should not panic")
.expect("read should succeed");
assert!(job_ids[10..].contains(&ctx.job_id));
}
assert_eq!(manager.all_jobs().await.len(), 10);
}
}
+272
View File
@@ -290,4 +290,276 @@ mod tests {
assert_eq!(memory.total_duration(), Duration::from_secs(3));
assert_eq!(memory.successful_actions(), 2);
}
#[test]
fn test_action_record_fail() {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50));
assert!(!action.success);
assert_eq!(action.error.as_deref(), Some("something went wrong"));
assert_eq!(action.duration, Duration::from_millis(50));
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
}
#[test]
fn test_action_record_with_warnings() {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
assert_eq!(action.sanitization_warnings.len(), 2);
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
assert_eq!(action.sanitization_warnings[1], "possible xss");
}
#[test]
fn test_action_record_with_cost() {
let action = ActionRecord::new(0, "expensive_tool", serde_json::json!({}));
let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost);
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
}
#[test]
fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
assert_eq!(action.sequence, 5);
assert_eq!(action.tool_name, "my_tool");
assert_eq!(action.input, serde_json::json!({"key": "val"}));
assert!(!action.success);
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
assert!(action.sanitization_warnings.is_empty());
assert!(action.cost.is_none());
assert_eq!(action.duration, Duration::ZERO);
assert!(action.error.is_none());
}
#[test]
fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed(
Some("raw output here".into()),
serde_json::json!({"clean": true}),
Duration::from_secs(7),
);
assert!(action.success);
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
assert_eq!(
action.output_sanitized,
Some(serde_json::json!({"clean": true}))
);
assert_eq!(action.duration, Duration::from_secs(7));
}
#[test]
fn test_conversation_memory_clear() {
let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi"));
assert_eq!(mem.len(), 2);
assert!(!mem.is_empty());
mem.clear();
assert_eq!(mem.len(), 0);
assert!(mem.is_empty());
assert!(mem.messages().is_empty());
}
#[test]
fn test_conversation_memory_last_n() {
let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("one"));
mem.add(ChatMessage::assistant("two"));
mem.add(ChatMessage::user("three"));
mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].content, "three");
assert_eq!(last_2[1].content, "four");
// Requesting more than available returns all
let last_100 = mem.last_n(100);
assert_eq!(last_100.len(), 4);
}
#[test]
fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10);
let result = mem.last_n(5);
assert!(result.is_empty());
}
#[test]
fn test_conversation_memory_preserves_system_message_on_trim() {
let mut mem = ConversationMemory::new(3);
mem.add(ChatMessage::system("You are helpful"));
mem.add(ChatMessage::user("msg1"));
mem.add(ChatMessage::user("msg2"));
// At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3"));
assert_eq!(mem.len(), 3);
// System message must survive
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[0].content, "You are helpful");
// Oldest non-system message (msg1) should be gone
assert_eq!(mem.messages()[1].content, "msg2");
assert_eq!(mem.messages()[2].content, "msg3");
}
#[test]
fn test_conversation_memory_trims_non_system_first() {
let mut mem = ConversationMemory::new(2);
mem.add(ChatMessage::system("sys"));
mem.add(ChatMessage::user("a"));
// Now at capacity. Add another.
mem.add(ChatMessage::user("b"));
assert_eq!(mem.len(), 2);
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[1].content, "b");
}
#[test]
fn test_conversation_memory_max_one_with_system_does_not_loop() {
// Edge case: max_messages = 1 and only a system message.
// Adding another message would try to trim but should not
// remove the system message and get stuck.
let mut mem = ConversationMemory::new(1);
mem.add(ChatMessage::system("sys"));
// The system message is already at capacity. Adding another
// cannot trim the system message, so we end up with 2 (graceful).
// The important thing is we don't infinite-loop.
mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max.
assert!(mem.len() <= 2);
}
#[test]
fn test_memory_failed_actions() {
let mut memory = Memory::new(Uuid::new_v4());
let ok = memory.create_action("good", serde_json::json!({})).succeed(
None,
serde_json::json!({}),
Duration::from_millis(1),
);
memory.record_action(ok);
let err = memory
.create_action("bad", serde_json::json!({}))
.fail("oops", Duration::from_millis(2));
memory.record_action(err);
assert_eq!(memory.successful_actions(), 1);
assert_eq!(memory.failed_actions(), 1);
}
#[test]
fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.last_action().is_none());
let a1 = memory
.create_action("first", serde_json::json!({}))
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a1);
let a2 = memory
.create_action("second", serde_json::json!({}))
.fail("nope", Duration::ZERO);
memory.record_action(a2);
let last = memory.last_action().unwrap();
assert_eq!(last.tool_name, "second");
}
#[test]
fn test_memory_actions_by_tool() {
let mut memory = Memory::new(Uuid::new_v4());
for _ in 0..3 {
let a = memory
.create_action("shell", serde_json::json!({}))
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a);
}
let a = memory.create_action("http", serde_json::json!({})).succeed(
None,
serde_json::json!({}),
Duration::ZERO,
);
memory.record_action(a);
assert_eq!(memory.actions_by_tool("shell").len(), 3);
assert_eq!(memory.actions_by_tool("http").len(), 1);
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
}
#[test]
fn test_memory_create_action_increments_sequence() {
let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a0.sequence, 0);
let a1 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a1.sequence, 1);
let a2 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a2.sequence, 2);
}
#[test]
fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.conversation.is_empty());
memory.add_message(ChatMessage::user("hello"));
memory.add_message(ChatMessage::assistant("hi"));
assert_eq!(memory.conversation.len(), 2);
assert_eq!(memory.conversation.messages()[0].content, "hello");
}
#[test]
fn test_memory_total_cost_with_no_cost_actions() {
let mut memory = Memory::new(Uuid::new_v4());
// Actions without cost should contribute zero
let a = memory
.create_action("free_tool", serde_json::json!({}))
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a);
assert_eq!(memory.total_cost(), Decimal::ZERO);
}
#[test]
fn test_memory_total_duration_mixed() {
let mut memory = Memory::new(Uuid::new_v4());
let a1 = memory.create_action("t1", serde_json::json!({})).succeed(
None,
serde_json::json!({}),
Duration::from_millis(100),
);
memory.record_action(a1);
let a2 = memory
.create_action("t2", serde_json::json!({}))
.fail("err", Duration::from_millis(200));
memory.record_action(a2);
// Both successful and failed actions contribute to total duration
assert_eq!(memory.total_duration(), Duration::from_millis(300));
}
}
+216
View File
@@ -238,4 +238,220 @@ mod tests {
let rate = collector.success_rate();
assert!((rate - 0.666).abs() < 0.01);
}
// --- QualityMetrics default ---
#[test]
fn test_quality_metrics_default() {
let m = QualityMetrics::default();
assert_eq!(m.total_actions, 0);
assert_eq!(m.successful_actions, 0);
assert_eq!(m.failed_actions, 0);
assert_eq!(m.total_time, Duration::ZERO);
assert_eq!(m.total_cost, Decimal::ZERO);
assert!(m.tool_metrics.is_empty());
assert!(m.error_types.is_empty());
}
// --- ToolMetrics::success_rate ---
#[test]
fn test_tool_metrics_success_rate_zero_calls() {
let tm = ToolMetrics::default();
assert_eq!(tm.success_rate(), 0.0);
}
#[test]
fn test_tool_metrics_success_rate_mixed() {
let tm = ToolMetrics {
calls: 4,
successes: 3,
failures: 1,
..Default::default()
};
assert!((tm.success_rate() - 0.75).abs() < f64::EPSILON);
}
#[test]
fn test_tool_metrics_success_rate_all_failures() {
let tm = ToolMetrics {
calls: 5,
successes: 0,
failures: 5,
..Default::default()
};
assert_eq!(tm.success_rate(), 0.0);
}
// --- MetricsCollector ---
#[test]
fn test_collector_default_is_new() {
let a = MetricsCollector::new();
let b = MetricsCollector::default();
assert_eq!(a.metrics().total_actions, b.metrics().total_actions);
assert_eq!(a.success_rate(), b.success_rate());
}
#[test]
fn test_success_rate_empty_collector() {
let collector = MetricsCollector::new();
assert_eq!(collector.success_rate(), 0.0);
}
#[test]
fn test_record_success_accumulates_cost() {
let mut c = MetricsCollector::new();
c.record_success("a", Duration::from_millis(100), Some(dec!(1.50)));
c.record_success("a", Duration::from_millis(200), Some(dec!(2.50)));
assert_eq!(c.metrics().total_cost, dec!(4.00));
let tool = c.tool_metrics("a").unwrap();
assert_eq!(tool.total_cost, dec!(4.00));
}
#[test]
fn test_record_success_none_cost_does_not_change_total() {
let mut c = MetricsCollector::new();
c.record_success("x", Duration::from_secs(1), Some(dec!(1.00)));
c.record_success("x", Duration::from_secs(1), None);
assert_eq!(c.metrics().total_cost, dec!(1.00));
}
#[test]
fn test_record_failure_does_not_add_cost() {
let mut c = MetricsCollector::new();
c.record_failure("t", "oops", Duration::from_secs(1));
assert_eq!(c.metrics().total_cost, Decimal::ZERO);
}
#[test]
fn test_tool_avg_time_updates() {
let mut c = MetricsCollector::new();
c.record_success("t", Duration::from_secs(2), None);
c.record_success("t", Duration::from_secs(4), None);
let tool = c.tool_metrics("t").unwrap();
// total 6s / 2 calls = 3s avg
assert_eq!(tool.avg_time, Duration::from_secs(3));
}
#[test]
fn test_total_time_across_success_and_failure() {
let mut c = MetricsCollector::new();
c.record_success("a", Duration::from_secs(3), None);
c.record_failure("b", "err", Duration::from_secs(7));
assert_eq!(c.metrics().total_time, Duration::from_secs(10));
}
#[test]
fn test_tool_metrics_returns_none_for_unknown() {
let c = MetricsCollector::new();
assert!(c.tool_metrics("nonexistent").is_none());
}
#[test]
fn test_reset_clears_everything() {
let mut c = MetricsCollector::new();
c.record_success("t", Duration::from_secs(1), Some(dec!(5.00)));
c.record_failure("t", "error", Duration::from_secs(1));
c.reset();
assert_eq!(c.metrics().total_actions, 0);
assert_eq!(c.metrics().successful_actions, 0);
assert_eq!(c.metrics().failed_actions, 0);
assert_eq!(c.metrics().total_cost, Decimal::ZERO);
assert!(c.metrics().tool_metrics.is_empty());
assert!(c.metrics().error_types.is_empty());
assert_eq!(c.success_rate(), 0.0);
}
#[test]
fn test_multiple_tools_tracked_independently() {
let mut c = MetricsCollector::new();
c.record_success("alpha", Duration::from_secs(1), None);
c.record_success("alpha", Duration::from_secs(1), None);
c.record_failure("beta", "bad", Duration::from_secs(1));
c.record_success("beta", Duration::from_secs(1), None);
let alpha = c.tool_metrics("alpha").unwrap();
assert_eq!(alpha.calls, 2);
assert_eq!(alpha.successes, 2);
assert_eq!(alpha.failures, 0);
let beta = c.tool_metrics("beta").unwrap();
assert_eq!(beta.calls, 2);
assert_eq!(beta.successes, 1);
assert_eq!(beta.failures, 1);
}
// --- categorize_error ---
#[test]
fn test_categorize_error_all_types() {
assert_eq!(categorize_error("Connection timeout"), "timeout");
assert_eq!(categorize_error("TIMEOUT exceeded"), "timeout");
assert_eq!(categorize_error("rate limit hit"), "rate_limit");
assert_eq!(categorize_error("Rate Limit 429"), "rate_limit");
assert_eq!(categorize_error("auth failure"), "auth");
assert_eq!(categorize_error("Unauthorized"), "auth");
assert_eq!(categorize_error("resource not found"), "not_found");
assert_eq!(categorize_error("HTTP 404"), "not_found");
assert_eq!(categorize_error("invalid parameter X"), "invalid_input");
assert_eq!(categorize_error("bad parameter"), "invalid_input");
assert_eq!(categorize_error("Invalid JSON"), "invalid_input");
assert_eq!(categorize_error("network error"), "network");
assert_eq!(categorize_error("connection refused"), "network");
assert_eq!(categorize_error("something else entirely"), "unknown");
assert_eq!(categorize_error(""), "unknown");
}
#[test]
fn test_error_types_accumulated_in_collector() {
let mut c = MetricsCollector::new();
c.record_failure("t", "timeout!", Duration::from_secs(1));
c.record_failure("t", "another timeout", Duration::from_secs(1));
c.record_failure("t", "auth denied", Duration::from_secs(1));
assert_eq!(c.metrics().error_types.get("timeout"), Some(&2));
assert_eq!(c.metrics().error_types.get("auth"), Some(&1));
}
// --- MetricsSummary ---
#[test]
fn test_summary_empty_collector() {
let c = MetricsCollector::new();
let s = c.summary();
assert_eq!(s.total_actions, 0);
assert_eq!(s.success_rate, 0.0);
assert_eq!(s.total_cost, Decimal::ZERO);
assert!(s.most_used_tool.is_none());
assert!(s.most_failed_tool.is_none());
assert!(s.top_errors.is_empty());
}
#[test]
fn test_summary_most_used_and_most_failed() {
let mut c = MetricsCollector::new();
// "alpha" gets 3 calls (all success)
c.record_success("alpha", Duration::from_secs(1), None);
c.record_success("alpha", Duration::from_secs(1), None);
c.record_success("alpha", Duration::from_secs(1), None);
// "beta" gets 2 calls (both failures)
c.record_failure("beta", "err", Duration::from_secs(1));
c.record_failure("beta", "err", Duration::from_secs(1));
let s = c.summary();
assert_eq!(s.most_used_tool.as_deref(), Some("alpha"));
assert_eq!(s.most_failed_tool.as_deref(), Some("beta"));
assert_eq!(s.total_actions, 5);
}
#[test]
fn test_summary_top_errors_populated() {
let mut c = MetricsCollector::new();
c.record_failure("t", "timeout", Duration::from_secs(1));
c.record_failure("t", "auth error", Duration::from_secs(1));
let s = c.summary();
assert!(!s.top_errors.is_empty());
assert!(s.top_errors.len() <= 3);
}
}
+254 -1
View File
@@ -331,6 +331,10 @@ mod tests {
}
fn create_action(success: bool) -> ActionRecord {
create_action_with_error(success, "Test error")
}
fn create_action_with_error(success: bool, error_msg: &str) -> ActionRecord {
let mut action = ActionRecord::new(0, "test", serde_json::json!({}));
if success {
action = action.succeed(
@@ -339,8 +343,257 @@ mod tests {
std::time::Duration::from_secs(1),
);
} else {
action = action.fail("Test error", std::time::Duration::from_secs(1));
action = action.fail(error_msg, std::time::Duration::from_secs(1));
}
action
}
fn completed_job(title: &str) -> JobContext {
let mut job = JobContext::new(title, "test job");
job.transition_to(crate::context::JobState::InProgress, None)
.unwrap();
job.transition_to(crate::context::JobState::Completed, None)
.unwrap();
job
}
// --- EvaluationResult construction ---
#[test]
fn test_evaluation_result_success_defaults() {
let result = EvaluationResult::success("all good", 85);
assert!(result.success);
assert_eq!(result.confidence, 0.9);
assert_eq!(result.reasoning, "all good");
assert!(result.issues.is_empty());
assert!(result.suggestions.is_empty());
assert_eq!(result.quality_score, 85);
}
#[test]
fn test_evaluation_result_failure_defaults() {
let issues = vec!["bad thing".to_string(), "worse thing".to_string()];
let result = EvaluationResult::failure("went wrong", issues.clone());
assert!(!result.success);
assert_eq!(result.confidence, 0.9);
assert_eq!(result.reasoning, "went wrong");
assert_eq!(result.issues, issues);
assert_eq!(result.quality_score, 0);
}
#[test]
fn test_evaluation_result_serde_roundtrip() {
let result = EvaluationResult {
success: true,
confidence: 0.75,
reasoning: "looks fine".to_string(),
issues: vec!["minor".to_string()],
suggestions: vec!["try harder".to_string()],
quality_score: 60,
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.success, result.success);
assert_eq!(deserialized.confidence, result.confidence);
assert_eq!(deserialized.reasoning, result.reasoning);
assert_eq!(deserialized.issues, result.issues);
assert_eq!(deserialized.suggestions, result.suggestions);
assert_eq!(deserialized.quality_score, result.quality_score);
}
// --- RuleBasedEvaluator builder ---
#[test]
fn test_rule_based_evaluator_default() {
let eval = RuleBasedEvaluator::default();
assert_eq!(eval.min_action_success_rate, 0.8);
assert_eq!(eval.max_failures, 3);
}
#[test]
fn test_rule_based_evaluator_builder_methods() {
let eval = RuleBasedEvaluator::new()
.with_min_success_rate(0.5)
.with_max_failures(10);
assert_eq!(eval.min_action_success_rate, 0.5);
assert_eq!(eval.max_failures, 10);
}
// --- RuleBasedEvaluator::evaluate edge cases ---
#[tokio::test]
async fn test_empty_actions_fails() {
let eval = RuleBasedEvaluator::new();
let job = completed_job("empty");
let result = eval.evaluate(&job, &[], None).await.unwrap();
assert!(!result.success);
assert!(result.issues.iter().any(|i| i.contains("No actions")));
}
#[tokio::test]
async fn test_all_actions_succeed_completed_job_gets_100() {
let eval = RuleBasedEvaluator::new();
let job = completed_job("perfect");
let actions = vec![
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(true),
];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(result.success);
// 100% success rate -> base 80, completion bonus 20 -> 100
assert_eq!(result.quality_score, 100);
}
#[tokio::test]
async fn test_quality_score_no_completion_bonus_for_pending_job() {
// Even if all actions succeed, a non-completed job gets flagged
let eval = RuleBasedEvaluator::new();
let job = JobContext::new("pending", "still pending");
let actions = vec![create_action(true)];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
// Job not in completed state => issues present
assert!(!result.success);
assert!(
result
.issues
.iter()
.any(|i| i.contains("not in completed state"))
);
}
#[tokio::test]
async fn test_submitted_state_counts_as_completed() {
let eval = RuleBasedEvaluator::new();
let mut job = JobContext::new("submitted", "test");
job.transition_to(crate::context::JobState::InProgress, None)
.unwrap();
job.transition_to(crate::context::JobState::Completed, None)
.unwrap();
job.transition_to(crate::context::JobState::Submitted, None)
.unwrap();
let actions = vec![create_action(true)];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
// Submitted is treated like completed for state check (no issue),
// but completion bonus only applies for Completed state
assert!(result.success);
}
#[tokio::test]
async fn test_success_rate_below_threshold_fails() {
let eval = RuleBasedEvaluator::new().with_min_success_rate(0.9);
let job = completed_job("threshold");
// 4 out of 5 = 80%, below 90% threshold
let actions = vec![
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(false),
];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(!result.success);
assert!(
result
.issues
.iter()
.any(|i| i.contains("success rate") && i.contains("below threshold"))
);
}
#[tokio::test]
async fn test_too_many_failures_flagged() {
let eval = RuleBasedEvaluator::new().with_max_failures(1);
let job = completed_job("failures");
// 8 successes, 2 failures: rate is 80% (passes default 0.8) but failures > max 1
let actions = vec![
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action(false),
create_action(false),
];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(!result.success);
assert!(
result
.issues
.iter()
.any(|i| i.contains("Too many failures"))
);
}
#[tokio::test]
async fn test_critical_error_detected() {
let eval = RuleBasedEvaluator::new().with_max_failures(10);
let job = completed_job("critical");
let actions = vec![
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action_with_error(false, "A CRITICAL system failure occurred"),
];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(!result.success);
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
}
#[tokio::test]
async fn test_fatal_error_detected() {
let eval = RuleBasedEvaluator::new().with_max_failures(10);
let job = completed_job("fatal");
let actions = vec![
create_action(true),
create_action(true),
create_action(true),
create_action(true),
create_action_with_error(false, "Fatal: disk full"),
];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
}
#[tokio::test]
async fn test_quality_score_capped_at_50_with_issues() {
let eval = RuleBasedEvaluator::new()
.with_min_success_rate(0.0)
.with_max_failures(100);
// Job not completed => issues present, quality capped
let job = JobContext::new("capped", "test");
let actions = vec![create_action(true)];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(!result.success);
assert!(result.quality_score <= 50);
}
#[tokio::test]
async fn test_failed_result_includes_suggestions() {
let eval = RuleBasedEvaluator::new().with_max_failures(0);
let job = completed_job("suggestions");
let actions = vec![create_action(false)];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(!result.success);
assert!(!result.suggestions.is_empty());
assert_eq!(result.confidence, 0.85);
}
#[tokio::test]
async fn test_single_successful_action_completed_job() {
let eval = RuleBasedEvaluator::new();
let job = completed_job("single");
let actions = vec![create_action(true)];
let result = eval.evaluate(&job, &actions, None).await.unwrap();
assert!(result.success);
// 100% rate -> base 80, + 20 completion = 100
assert_eq!(result.quality_score, 100);
assert!(result.reasoning.contains("1/1"));
}
}
+176
View File
@@ -325,4 +325,180 @@ mod tests {
// Just make sure it constructs without panicking
let _discovery = OnlineDiscovery::new();
}
#[test]
fn test_titlecase_single_char() {
assert_eq!(titlecase("a"), "A");
assert_eq!(titlecase("Z"), "Z");
}
#[test]
fn test_titlecase_mixed_case() {
assert_eq!(titlecase("hELLO wORLD"), "HELLO WORLD");
// Only first char is uppercased, rest is left as-is
assert_eq!(titlecase("alREADY weird"), "AlREADY Weird");
}
#[test]
fn test_titlecase_multiple_spaces() {
// split_whitespace collapses multiple spaces
assert_eq!(titlecase("hello world"), "Hello World");
assert_eq!(titlecase(" leading trailing "), "Leading Trailing");
}
#[test]
fn test_titlecase_punctuation() {
assert_eq!(titlecase("hello-world"), "Hello-world");
assert_eq!(titlecase("it's fine"), "It's Fine");
assert_eq!(titlecase("one. two"), "One. Two");
}
#[test]
fn test_extract_source_wasm_download() {
let src = ExtensionSource::WasmDownload {
wasm_url: "https://example.com/tool.wasm".to_string(),
capabilities_url: Some("https://example.com/caps.json".to_string()),
};
assert_eq!(extract_source(&src), "https://example.com/tool.wasm");
let src_no_caps = ExtensionSource::WasmDownload {
wasm_url: "https://other.com/bin.wasm".to_string(),
capabilities_url: None,
};
assert_eq!(extract_source(&src_no_caps), "https://other.com/bin.wasm");
}
#[test]
fn test_extract_source_wasm_buildable() {
let src = ExtensionSource::WasmBuildable {
source_dir: "/home/user/my-tool".to_string(),
build_dir: Some("/home/user/my-tool/target".to_string()),
crate_name: Some("my_tool".to_string()),
};
assert_eq!(extract_source(&src), "/home/user/my-tool");
let src_minimal = ExtensionSource::WasmBuildable {
source_dir: "./src".to_string(),
build_dir: None,
crate_name: None,
};
assert_eq!(extract_source(&src_minimal), "./src");
}
#[test]
fn test_online_discovery_default() {
let d = OnlineDiscovery::default();
// Verify it constructed (no panic) and the client is usable
let _ = d.http_client;
}
#[test]
fn test_github_search_response_empty_items() {
let json = r#"{"total_count": 0, "items": []}"#;
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
assert!(resp.items.is_empty());
}
#[test]
fn test_github_search_response_missing_items_field() {
// items has #[serde(default)], so missing field should give empty vec
let json = r#"{"total_count": 0}"#;
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
assert!(resp.items.is_empty());
}
#[test]
fn test_github_search_response_multiple_items() {
let json = r#"{
"items": [
{
"name": "mcp-server-a",
"full_name": "org/mcp-server-a",
"html_url": "https://github.com/org/mcp-server-a",
"description": "First server",
"topics": ["mcp"]
},
{
"name": "mcp-server-b",
"full_name": "org/mcp-server-b",
"html_url": "https://github.com/org/mcp-server-b",
"description": null,
"topics": ["mcp", "tools"]
}
]
}"#;
let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.items.len(), 2);
assert_eq!(resp.items[0].name, "mcp-server-a");
assert_eq!(resp.items[1].name, "mcp-server-b");
assert_eq!(resp.items[0].description, Some("First server".to_string()));
assert!(resp.items[1].description.is_none());
}
#[test]
fn test_github_repo_all_fields() {
let json = r#"{
"name": "cool-mcp",
"full_name": "user/cool-mcp",
"html_url": "https://github.com/user/cool-mcp",
"description": "A cool MCP server",
"homepage": "https://cool-mcp.dev",
"topics": ["mcp-server", "model-context-protocol", "rust"]
}"#;
let repo: super::GitHubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "cool-mcp");
assert_eq!(repo.full_name, "user/cool-mcp");
assert_eq!(repo.html_url, "https://github.com/user/cool-mcp");
assert_eq!(repo.description.as_deref(), Some("A cool MCP server"));
assert_eq!(repo.homepage.as_deref(), Some("https://cool-mcp.dev"));
assert_eq!(repo.topics.len(), 3);
}
#[test]
fn test_github_repo_missing_optional_fields() {
let json = r#"{
"name": "bare-repo",
"full_name": "user/bare-repo",
"html_url": "https://github.com/user/bare-repo"
}"#;
let repo: super::GitHubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "bare-repo");
assert!(repo.description.is_none());
assert!(repo.homepage.is_none());
assert!(repo.topics.is_empty());
}
#[tokio::test]
async fn test_with_timeout_completes() {
use crate::extensions::discovery::with_timeout;
let result = with_timeout(async { 42 }, std::time::Duration::from_secs(1)).await;
assert_eq!(result, Some(42));
}
#[tokio::test]
async fn test_with_timeout_expires() {
use crate::extensions::discovery::with_timeout;
let result = with_timeout(
tokio::time::sleep(std::time::Duration::from_secs(5)),
std::time::Duration::from_millis(10),
)
.await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_discover_empty_query() {
let discovery = OnlineDiscovery::new();
let results = discovery.discover("").await;
assert!(results.is_empty());
}
#[tokio::test]
async fn test_discover_whitespace_only_query() {
let discovery = OnlineDiscovery::new();
let results = discovery.discover(" \t\n ").await;
assert!(results.is_empty());
}
}
+414
View File
@@ -617,4 +617,418 @@ mod tests {
assert!(result.instructions().is_none());
assert!(result.setup_url().is_none());
}
// ── ExtensionKind ────────────────────────────────────────────────
#[test]
fn extension_kind_display() {
assert_eq!(ExtensionKind::McpServer.to_string(), "mcp_server");
assert_eq!(ExtensionKind::WasmTool.to_string(), "wasm_tool");
assert_eq!(ExtensionKind::WasmChannel.to_string(), "wasm_channel");
}
#[test]
fn extension_kind_serde_roundtrip() {
for kind in [
ExtensionKind::McpServer,
ExtensionKind::WasmTool,
ExtensionKind::WasmChannel,
] {
let json = serde_json::to_value(kind).unwrap();
let back: ExtensionKind = serde_json::from_value(json).unwrap();
assert_eq!(back, kind);
}
// Verify the serialized strings match rename_all = "snake_case"
assert_eq!(
serde_json::to_value(ExtensionKind::McpServer).unwrap(),
"mcp_server"
);
assert_eq!(
serde_json::to_value(ExtensionKind::WasmTool).unwrap(),
"wasm_tool"
);
assert_eq!(
serde_json::to_value(ExtensionKind::WasmChannel).unwrap(),
"wasm_channel"
);
}
// ── ExtensionSource ──────────────────────────────────────────────
#[test]
fn extension_source_serde_mcp_url() {
let src = ExtensionSource::McpUrl {
url: "https://mcp.example.com".to_string(),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "mcp_url");
assert_eq!(json["url"], "https://mcp.example.com");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::McpUrl { url } if url == "https://mcp.example.com")
);
}
#[test]
fn extension_source_serde_wasm_download() {
let src = ExtensionSource::WasmDownload {
wasm_url: "https://cdn.example.com/tool.wasm".to_string(),
capabilities_url: Some("https://cdn.example.com/caps.json".to_string()),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "wasm_download");
assert_eq!(json["wasm_url"], "https://cdn.example.com/tool.wasm");
assert_eq!(
json["capabilities_url"],
"https://cdn.example.com/caps.json"
);
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::WasmDownload { capabilities_url: Some(c), .. } if c.contains("caps.json"))
);
}
#[test]
fn extension_source_serde_wasm_buildable() {
let src = ExtensionSource::WasmBuildable {
source_dir: "/home/user/tools/my-tool".to_string(),
build_dir: Some("target/wasm32-wasip2/release".to_string()),
crate_name: Some("my_tool".to_string()),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "wasm_buildable");
assert_eq!(json["source_dir"], "/home/user/tools/my-tool");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::WasmBuildable { source_dir, .. } if source_dir.contains("my-tool"))
);
}
#[test]
fn extension_source_serde_discovered() {
let src = ExtensionSource::Discovered {
url: "https://discovered.example.com".to_string(),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "discovered");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(matches!(back, ExtensionSource::Discovered { url } if url.contains("discovered")));
}
// ── AuthHint ─────────────────────────────────────────────────────
#[test]
fn auth_hint_serde_all_variants() {
// Dcr
let json = serde_json::to_value(&AuthHint::Dcr).unwrap();
assert_eq!(json["type"], "dcr");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::Dcr));
// OAuthPreConfigured
let hint = AuthHint::OAuthPreConfigured {
setup_url: "https://dev.example.com/apps".to_string(),
};
let json = serde_json::to_value(&hint).unwrap();
assert_eq!(json["type"], "o_auth_pre_configured");
assert_eq!(json["setup_url"], "https://dev.example.com/apps");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(
matches!(back, AuthHint::OAuthPreConfigured { setup_url } if setup_url.contains("dev.example"))
);
// CapabilitiesAuth
let json = serde_json::to_value(&AuthHint::CapabilitiesAuth).unwrap();
assert_eq!(json["type"], "capabilities_auth");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::CapabilitiesAuth));
// None
let json = serde_json::to_value(&AuthHint::None).unwrap();
assert_eq!(json["type"], "none");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::None));
}
// ── SearchResult ─────────────────────────────────────────────────
#[test]
fn search_result_serde_registry_source() {
// SearchResult uses #[serde(flatten)] on entry, which means
// RegistryEntry.source (ExtensionSource) and SearchResult.source
// (ResultSource) collide on the "source" key. The last writer wins
// during serialization, so we test serialize-only (no roundtrip).
let entry = RegistryEntry {
name: "notion".to_string(),
display_name: "Notion".to_string(),
kind: ExtensionKind::McpServer,
description: "Notion integration".to_string(),
keywords: vec!["notes".to_string(), "wiki".to_string()],
source: ExtensionSource::McpUrl {
url: "https://mcp.notion.so".to_string(),
},
fallback_source: None,
auth_hint: AuthHint::Dcr,
};
let sr = SearchResult {
entry,
source: ResultSource::Registry,
validated: false,
};
let json = serde_json::to_value(&sr).unwrap();
assert_eq!(json["name"], "notion");
assert_eq!(json["kind"], "mcp_server");
assert_eq!(json["description"], "Notion integration");
assert_eq!(json["validated"], false);
// The flattened entry fields are present at the top level
assert!(json.get("auth_hint").is_some());
assert_eq!(json["keywords"].as_array().unwrap().len(), 2);
}
#[test]
fn search_result_serde_discovered_source() {
let entry = RegistryEntry {
name: "custom-api".to_string(),
display_name: "Custom API".to_string(),
kind: ExtensionKind::McpServer,
description: "Discovered MCP server".to_string(),
keywords: vec![],
source: ExtensionSource::Discovered {
url: "https://custom.example.com/.well-known/mcp".to_string(),
},
fallback_source: None,
auth_hint: AuthHint::None,
};
let sr = SearchResult {
entry,
source: ResultSource::Discovered,
validated: true,
};
let json = serde_json::to_value(&sr).unwrap();
assert_eq!(json["name"], "custom-api");
assert_eq!(json["display_name"], "Custom API");
assert_eq!(json["validated"], true);
assert!(json.get("keywords").is_some());
}
// ── InstallResult ────────────────────────────────────────────────
#[test]
fn install_result_serde_roundtrip() {
let ir = InstallResult {
name: "weather".to_string(),
kind: ExtensionKind::WasmTool,
message: "Installed successfully".to_string(),
};
let json = serde_json::to_value(&ir).unwrap();
assert_eq!(json["name"], "weather");
assert_eq!(json["kind"], "wasm_tool");
assert_eq!(json["message"], "Installed successfully");
let back: InstallResult = serde_json::from_value(json).unwrap();
assert_eq!(back.name, "weather");
assert_eq!(back.kind, ExtensionKind::WasmTool);
}
// ── ActivateResult ───────────────────────────────────────────────
#[test]
fn activate_result_serde_roundtrip() {
let ar = ActivateResult {
name: "slack".to_string(),
kind: ExtensionKind::WasmChannel,
tools_loaded: vec!["send_message".to_string(), "read_channel".to_string()],
message: "Activated with 2 tools".to_string(),
};
let json = serde_json::to_value(&ar).unwrap();
assert_eq!(json["name"], "slack");
assert_eq!(json["kind"], "wasm_channel");
assert_eq!(json["tools_loaded"].as_array().unwrap().len(), 2);
let back: ActivateResult = serde_json::from_value(json).unwrap();
assert_eq!(back.tools_loaded, vec!["send_message", "read_channel"]);
}
// ── InstalledExtension ───────────────────────────────────────────
#[test]
fn installed_extension_serde_defaults() {
// Minimal JSON: optional fields absent, defaults kick in
let json = serde_json::json!({
"name": "echo",
"kind": "wasm_tool",
"authenticated": false,
"active": false,
});
let ext: InstalledExtension = serde_json::from_value(json).unwrap();
assert_eq!(ext.name, "echo");
assert!(ext.installed, "installed should default to true");
assert!(!ext.needs_setup, "needs_setup should default to false");
assert!(!ext.has_auth);
assert!(ext.tools.is_empty());
assert!(ext.display_name.is_none());
assert!(ext.description.is_none());
assert!(ext.url.is_none());
assert!(ext.activation_error.is_none());
}
#[test]
fn installed_extension_serde_all_fields() {
let ext = InstalledExtension {
name: "gmail".to_string(),
kind: ExtensionKind::WasmTool,
display_name: Some("Gmail Tool".to_string()),
description: Some("Read and send emails".to_string()),
url: Some("https://gmail.example.com".to_string()),
authenticated: true,
active: true,
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
needs_setup: true,
has_auth: true,
installed: false,
activation_error: Some("token expired".to_string()),
};
let json = serde_json::to_value(&ext).unwrap();
assert_eq!(json["display_name"], "Gmail Tool");
assert_eq!(json["description"], "Read and send emails");
assert_eq!(json["url"], "https://gmail.example.com");
assert_eq!(json["needs_setup"], true);
assert_eq!(json["installed"], false);
assert_eq!(json["activation_error"], "token expired");
let back: InstalledExtension = serde_json::from_value(json).unwrap();
assert_eq!(back.name, "gmail");
assert_eq!(back.tools.len(), 2);
assert!(back.needs_setup);
assert!(!back.installed);
assert_eq!(back.activation_error.as_deref(), Some("token expired"));
}
// ── ExtensionError Display ───────────────────────────────────────
#[test]
fn extension_error_display_all_variants() {
let cases: Vec<(ExtensionError, &str)> = vec![
(
ExtensionError::NotFound("foo".into()),
"Extension not found: foo",
),
(
ExtensionError::AlreadyInstalled("bar".into()),
"Extension already installed: bar",
),
(
ExtensionError::NotInstalled("baz".into()),
"Extension not installed: baz",
),
(
ExtensionError::AuthFailed("bad token".into()),
"Authentication failed: bad token",
),
(
ExtensionError::ActivationFailed("crash".into()),
"Activation failed: crash",
),
(
ExtensionError::InstallFailed("disk full".into()),
"Installation failed: disk full",
),
(
ExtensionError::DiscoveryFailed("timeout".into()),
"Discovery failed: timeout",
),
(
ExtensionError::InvalidUrl("not a url".into()),
"Invalid URL: not a url",
),
(
ExtensionError::DownloadFailed("404".into()),
"Download failed: 404",
),
(
ExtensionError::Config("missing key".into()),
"Config error: missing key",
),
(
ExtensionError::Other("something broke".into()),
"something broke",
),
(
ExtensionError::FallbackFailed {
primary: Box::new(ExtensionError::DownloadFailed("404".into())),
fallback: Box::new(ExtensionError::InstallFailed("no cargo".into())),
},
"Primary install failed: Download failed: 404; fallback install also failed: Installation failed: no cargo",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
// ── ToolAuthState ────────────────────────────────────────────────
#[test]
fn tool_auth_state_equality() {
assert_eq!(ToolAuthState::Ready, ToolAuthState::Ready);
assert_eq!(ToolAuthState::NeedsAuth, ToolAuthState::NeedsAuth);
assert_eq!(ToolAuthState::NeedsSetup, ToolAuthState::NeedsSetup);
assert_eq!(ToolAuthState::NoAuth, ToolAuthState::NoAuth);
assert_ne!(ToolAuthState::Ready, ToolAuthState::NeedsAuth);
assert_ne!(ToolAuthState::NeedsSetup, ToolAuthState::NoAuth);
assert_ne!(ToolAuthState::Ready, ToolAuthState::NoAuth);
}
// ── ResultSource ─────────────────────────────────────────────────
#[test]
fn result_source_serde() {
let json = serde_json::to_value(ResultSource::Registry).unwrap();
assert_eq!(json, "registry");
let back: ResultSource = serde_json::from_value(json).unwrap();
assert_eq!(back, ResultSource::Registry);
let json = serde_json::to_value(ResultSource::Discovered).unwrap();
assert_eq!(json, "discovered");
let back: ResultSource = serde_json::from_value(json).unwrap();
assert_eq!(back, ResultSource::Discovered);
}
// ── AuthResult::status_str ───────────────────────────────────────
#[test]
fn auth_result_status_str_all_variants() {
assert_eq!(
AuthResult::authenticated("a", ExtensionKind::McpServer).status_str(),
"authenticated"
);
assert_eq!(
AuthResult::no_auth_required("b", ExtensionKind::WasmTool).status_str(),
"no_auth_required"
);
assert_eq!(
AuthResult::awaiting_authorization(
"c",
ExtensionKind::WasmChannel,
"https://x.com".into(),
"local".into(),
)
.status_str(),
"awaiting_authorization"
);
assert_eq!(
AuthResult::awaiting_token("d", ExtensionKind::WasmTool, "paste token".into(), None)
.status_str(),
"awaiting_token"
);
assert_eq!(
AuthResult::needs_setup(
"e",
ExtensionKind::McpServer,
"configure oauth".into(),
Some("https://setup.example.com".into()),
)
.status_str(),
"needs_setup"
);
}
}
+595
View File
@@ -1521,4 +1521,599 @@ mod tests {
std::env::remove_var("NEARAI_API_KEY");
}
}
// -- ModelInfo serde alias tests ------------------------------------------
#[test]
fn test_model_info_deserialize_with_name_field() {
let json = r#"{"name": "claude-3-5-sonnet"}"#;
let info: ModelInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "claude-3-5-sonnet");
assert!(info.provider.is_none());
}
#[test]
fn test_model_info_deserialize_with_id_alias() {
let json = r#"{"id": "gpt-4o", "provider": "openai"}"#;
let info: ModelInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "gpt-4o");
assert_eq!(info.provider, Some("openai".to_string()));
}
#[test]
fn test_model_info_deserialize_with_model_alias() {
let json = r#"{"model": "llama-3.1-70b"}"#;
let info: ModelInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "llama-3.1-70b");
}
#[test]
fn test_model_info_roundtrip_serializes_as_name() {
let info = ModelInfo {
name: "test-model".to_string(),
provider: Some("nearai".to_string()),
};
let json = serde_json::to_value(&info).unwrap();
// Serialization always uses the field name "name", not the aliases
assert_eq!(json["name"], "test-model");
assert_eq!(json["provider"], "nearai");
assert!(json.get("id").is_none());
assert!(json.get("model").is_none());
}
// -- ChatCompletionRequest serialization ----------------------------------
#[test]
fn test_request_serialization_minimal() {
let req = ChatCompletionRequest {
model: "gpt-4o".to_string(),
messages: vec![ChatCompletionMessage {
role: "user".to_string(),
content: Some("Hello".to_string()),
tool_call_id: None,
name: None,
tool_calls: None,
}],
temperature: None,
max_tokens: None,
tools: None,
tool_choice: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["model"], "gpt-4o");
assert_eq!(json["messages"][0]["role"], "user");
assert_eq!(json["messages"][0]["content"], "Hello");
// Optional fields should be absent, not null
assert!(json.get("temperature").is_none());
assert!(json.get("max_tokens").is_none());
assert!(json.get("tools").is_none());
assert!(json.get("tool_choice").is_none());
}
#[test]
fn test_request_serialization_with_tools() {
let req = ChatCompletionRequest {
model: "gpt-4o".to_string(),
messages: vec![],
temperature: Some(0.7),
max_tokens: Some(1024),
tools: Some(vec![ChatCompletionTool {
tool_type: "function".to_string(),
function: ChatCompletionFunction {
name: "get_weather".to_string(),
description: Some("Get the weather".to_string()),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string"}
}
})),
},
}]),
tool_choice: Some("auto".to_string()),
};
let json = serde_json::to_value(&req).unwrap();
// f32 precision: 0.7f32 serializes as 0.699999988... in JSON
let temp = json["temperature"].as_f64().unwrap();
assert!(
(temp - 0.7).abs() < 0.001,
"temperature should be ~0.7, got {temp}"
);
assert_eq!(json["max_tokens"], 1024);
assert_eq!(json["tool_choice"], "auto");
// Tool uses "type" key (via rename), not "tool_type"
assert_eq!(json["tools"][0]["type"], "function");
assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_request_omits_null_content_on_assistant_messages() {
// When an assistant message has tool_calls but no content, content
// should serialize as absent (skip_serializing_if) not "content": null.
let msg = ChatCompletionMessage {
role: "assistant".to_string(),
content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ChatCompletionToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: ChatCompletionToolCallFunction {
name: "echo".to_string(),
arguments: "{}".to_string(),
},
}]),
};
let json = serde_json::to_value(&msg).unwrap();
assert!(
json.get("content").is_none(),
"content should be omitted when None"
);
assert!(json.get("tool_call_id").is_none());
assert!(json.get("name").is_none());
assert!(json["tool_calls"].is_array());
}
// -- ChatCompletionResponse deserialization -------------------------------
#[test]
fn test_response_deserialize_basic() {
let json = serde_json::json!({
"id": "chatcmpl-abc123",
"object": "chat.completion",
"choices": [{
"message": {
"role": "assistant",
"content": "Hello!"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
});
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.id, Some("chatcmpl-abc123".to_string()));
assert_eq!(resp.choices.len(), 1);
assert_eq!(resp.choices[0].message.content, Some("Hello!".to_string()));
assert_eq!(resp.choices[0].finish_reason, Some("stop".to_string()));
let usage = resp.usage.unwrap();
assert_eq!(usage.prompt_tokens, Some(10));
assert_eq!(usage.completion_tokens, Some(5));
assert_eq!(usage.total_tokens, Some(15));
}
#[test]
fn test_response_deserialize_missing_optional_fields() {
// Minimal response: no id, no usage, no finish_reason
let json = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": "Hi"
},
"finish_reason": null
}]
});
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
assert!(resp.id.is_none());
assert!(resp.usage.is_none());
assert!(resp.choices[0].finish_reason.is_none());
}
#[test]
fn test_response_deserialize_with_tool_calls() {
let json = serde_json::json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"NYC\"}"
}
},
{
"id": "call_def",
"type": "function",
"function": {
"name": "get_time",
"arguments": "{}"
}
}
]
},
"finish_reason": "tool_calls"
}]
});
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
let tc = resp.choices[0].message.tool_calls.as_ref().unwrap();
assert_eq!(tc.len(), 2);
assert_eq!(tc[0].id, "call_abc");
assert_eq!(tc[0].function.name, "get_weather");
assert_eq!(tc[0].function.arguments, "{\"city\":\"NYC\"}");
assert_eq!(tc[1].id, "call_def");
assert_eq!(tc[1].function.name, "get_time");
}
#[test]
fn test_response_deserialize_ignores_unknown_fields() {
// Real API responses have extra fields like "object", "created", "model"
let json = serde_json::json!({
"id": "chatcmpl-xyz",
"object": "chat.completion",
"created": 1700000000,
"model": "gpt-4o",
"system_fingerprint": "fp_abc123",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "ok"
},
"finish_reason": "stop",
"logprobs": null
}],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 1,
"total_tokens": 6
}
});
let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.choices[0].message.content, Some("ok".to_string()));
}
// -- parse_usage and saturate_u32 -----------------------------------------
#[test]
fn test_parse_usage_with_all_fields() {
let usage = ChatCompletionUsage {
prompt_tokens: Some(100),
completion_tokens: Some(50),
total_tokens: Some(150),
};
assert_eq!(parse_usage(Some(&usage)), (100, 50));
}
#[test]
fn test_parse_usage_none() {
assert_eq!(parse_usage(None), (0, 0));
}
#[test]
fn test_parse_usage_missing_completion_falls_back_to_total_minus_prompt() {
let usage = ChatCompletionUsage {
prompt_tokens: Some(100),
completion_tokens: None,
total_tokens: Some(180),
};
// output = total - prompt = 80
assert_eq!(parse_usage(Some(&usage)), (100, 80));
}
#[test]
fn test_parse_usage_missing_completion_and_prompt_uses_total() {
let usage = ChatCompletionUsage {
prompt_tokens: None,
completion_tokens: None,
total_tokens: Some(200),
};
// input = 0 (no prompt), output = total = 200
assert_eq!(parse_usage(Some(&usage)), (0, 200));
}
#[test]
fn test_parse_usage_all_none() {
let usage = ChatCompletionUsage {
prompt_tokens: None,
completion_tokens: None,
total_tokens: None,
};
assert_eq!(parse_usage(Some(&usage)), (0, 0));
}
#[test]
fn test_saturate_u32_within_range() {
assert_eq!(saturate_u32(0), 0);
assert_eq!(saturate_u32(42), 42);
assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX);
}
#[test]
fn test_saturate_u32_overflow_clamps() {
assert_eq!(saturate_u32(u32::MAX as u64 + 1), u32::MAX);
assert_eq!(saturate_u32(u64::MAX), u32::MAX);
}
// -- Pricing types deserialization ----------------------------------------
#[test]
fn test_model_cost_deserialize() {
let json = r#"{"amount": 3.0, "scale": 6}"#;
let mc: ModelCost = serde_json::from_str(json).unwrap();
assert_eq!(mc.amount, 3.0);
assert_eq!(mc.scale, 6);
}
#[test]
fn test_model_cost_scale_defaults_to_zero() {
let json = r#"{"amount": 0.5}"#;
let mc: ModelCost = serde_json::from_str(json).unwrap();
assert_eq!(mc.scale, 0);
}
#[test]
fn test_model_cost_to_decimal_negative_scale() {
// amount=2, scale=-3 → 2 * 10^3 = 2000
let mc = ModelCost {
amount: 2.0,
scale: -3,
};
let result = model_cost_to_decimal(&mc).unwrap();
assert_eq!(result, dec!(2000));
}
#[test]
fn test_pricing_model_entry_deserialize_camel_case_aliases() {
let json = serde_json::json!({
"modelId": "claude-3-5-sonnet",
"inputCostPerToken": {"amount": 3.0, "scale": 6},
"outputCostPerToken": {"amount": 15.0, "scale": 6},
"metadata": {"aliases": ["claude-sonnet", "claude-3.5-sonnet"]}
});
let entry: PricingModelEntry = serde_json::from_value(json).unwrap();
assert_eq!(entry.model_id, Some("claude-3-5-sonnet".to_string()));
let input = model_cost_to_decimal(entry.input_cost_per_token.as_ref().unwrap()).unwrap();
assert_eq!(input, dec!(0.000003));
let output = model_cost_to_decimal(entry.output_cost_per_token.as_ref().unwrap()).unwrap();
assert_eq!(output, dec!(0.000015));
assert_eq!(
entry.metadata.unwrap().aliases,
vec!["claude-sonnet", "claude-3.5-sonnet"]
);
}
#[test]
fn test_pricing_model_entry_deserialize_snake_case() {
let json = serde_json::json!({
"model_id": "gpt-4o",
"input_cost_per_token": {"amount": 5.0, "scale": 6},
"output_cost_per_token": {"amount": 15.0, "scale": 6}
});
let entry: PricingModelEntry = serde_json::from_value(json).unwrap();
assert_eq!(entry.model_id, Some("gpt-4o".to_string()));
assert!(entry.input_cost_per_token.is_some());
assert!(entry.metadata.is_none());
}
#[test]
fn test_pricing_response_models_wrapper() {
let json = serde_json::json!({
"models": [
{"model_id": "m1", "input_cost_per_token": {"amount": 1.0, "scale": 6},
"output_cost_per_token": {"amount": 2.0, "scale": 6}}
]
});
let resp: PricingResponse = serde_json::from_value(json).unwrap();
assert!(resp.models.is_some());
assert_eq!(resp.models.unwrap().len(), 1);
assert!(resp.data.is_none());
}
#[test]
fn test_pricing_response_data_wrapper() {
let json = serde_json::json!({
"data": [
{"model_id": "m1"},
{"model_id": "m2"}
]
});
let resp: PricingResponse = serde_json::from_value(json).unwrap();
assert!(resp.models.is_none());
assert_eq!(resp.data.unwrap().len(), 2);
}
// -- flatten_tool_messages edge cases -------------------------------------
#[test]
fn test_flatten_tool_result_missing_name_uses_unknown() {
let messages = vec![ChatCompletionMessage {
role: "tool".to_string(),
content: Some("result data".to_string()),
tool_call_id: Some("call_1".to_string()),
name: None,
tool_calls: None,
}];
let result = flatten_tool_messages(messages);
assert_eq!(result[0].role, "user");
assert!(
result[0]
.content
.as_ref()
.unwrap()
.contains("[Tool `unknown` returned:")
);
}
#[test]
fn test_flatten_tool_result_missing_content_uses_empty() {
let messages = vec![ChatCompletionMessage {
role: "tool".to_string(),
content: None,
tool_call_id: Some("call_1".to_string()),
name: Some("my_tool".to_string()),
tool_calls: None,
}];
let result = flatten_tool_messages(messages);
assert_eq!(result[0].role, "user");
assert!(
result[0]
.content
.as_ref()
.unwrap()
.contains("[Tool `my_tool` returned: ]")
);
}
#[test]
fn test_flatten_multiple_tool_calls_in_single_assistant_message() {
let messages = vec![
ChatCompletionMessage {
role: "assistant".to_string(),
content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![
ChatCompletionToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: ChatCompletionToolCallFunction {
name: "search".to_string(),
arguments: r#"{"q":"a"}"#.to_string(),
},
},
ChatCompletionToolCall {
id: "call_2".to_string(),
call_type: "function".to_string(),
function: ChatCompletionToolCallFunction {
name: "fetch".to_string(),
arguments: r#"{"url":"http://x"}"#.to_string(),
},
},
]),
},
ChatCompletionMessage {
role: "tool".to_string(),
content: Some("found".to_string()),
tool_call_id: Some("call_1".to_string()),
name: Some("search".to_string()),
tool_calls: None,
},
ChatCompletionMessage {
role: "tool".to_string(),
content: Some("fetched".to_string()),
tool_call_id: Some("call_2".to_string()),
name: Some("fetch".to_string()),
tool_calls: None,
},
];
let result = flatten_tool_messages(messages);
assert_eq!(result.len(), 3);
// Assistant message has both calls described
let assistant_text = result[0].content.as_ref().unwrap();
assert!(assistant_text.contains("[Called tool `search`"));
assert!(assistant_text.contains("[Called tool `fetch`"));
assert!(result[0].tool_calls.is_none());
// Both tool results become user messages
assert_eq!(result[1].role, "user");
assert_eq!(result[2].role, "user");
}
// -- ChatMessage → ChatCompletionMessage edge cases -----------------------
#[test]
fn test_assistant_empty_content_with_tool_calls_becomes_none() {
// When content is empty string and tool_calls are present, content
// should be None to avoid sending `"content": ""` which some APIs reject.
let msg = ChatMessage::assistant_with_tool_calls(
None,
vec![ToolCall {
id: "call_1".to_string(),
name: "test".to_string(),
arguments: serde_json::json!({}),
}],
);
let chat_msg: ChatCompletionMessage = msg.into();
assert!(
chat_msg.content.is_none(),
"empty content with tool_calls should serialize as None"
);
}
#[test]
fn test_system_message_conversion() {
let msg = ChatMessage::system("You are a helpful assistant.");
let chat_msg: ChatCompletionMessage = msg.into();
assert_eq!(chat_msg.role, "system");
assert_eq!(
chat_msg.content,
Some("You are a helpful assistant.".to_string())
);
assert!(chat_msg.tool_calls.is_none());
assert!(chat_msg.tool_call_id.is_none());
}
// -- ChatCompletionUsage deserialization -----------------------------------
#[test]
fn test_usage_deserialize_partial_fields() {
// Some providers only return total_tokens
let json = r#"{"total_tokens": 500}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert!(usage.prompt_tokens.is_none());
assert!(usage.completion_tokens.is_none());
assert_eq!(usage.total_tokens, Some(500));
}
#[test]
fn test_usage_deserialize_empty_object() {
let json = "{}";
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert!(usage.prompt_tokens.is_none());
assert!(usage.completion_tokens.is_none());
assert!(usage.total_tokens.is_none());
}
// -- ChatCompletionToolCall serde roundtrip --------------------------------
#[test]
fn test_tool_call_serde_roundtrip() {
let tc = ChatCompletionToolCall {
id: "call_abc".to_string(),
call_type: "function".to_string(),
function: ChatCompletionToolCallFunction {
name: "get_weather".to_string(),
arguments: r#"{"city":"London"}"#.to_string(),
},
};
let json = serde_json::to_value(&tc).unwrap();
// "type" not "call_type" in serialized form
assert_eq!(json["type"], "function");
assert!(json.get("call_type").is_none());
assert_eq!(json["id"], "call_abc");
// Deserialize back
let deserialized: ChatCompletionToolCall = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.id, "call_abc");
assert_eq!(deserialized.call_type, "function");
assert_eq!(deserialized.function.name, "get_weather");
assert_eq!(deserialized.function.arguments, r#"{"city":"London"}"#);
}
// -- api_url edge cases ---------------------------------------------------
#[test]
fn test_api_url_with_trailing_v1_slash() {
let cfg = test_nearai_config("http://example.com/v1/");
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
// Trailing slash gets trimmed, then /v1 is detected
assert_eq!(provider.api_url("models"), "http://example.com/v1/models");
}
#[test]
fn test_api_url_with_deep_base_path() {
let cfg = test_nearai_config("http://example.com/api/proxy");
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
assert_eq!(
provider.api_url("chat/completions"),
"http://example.com/api/proxy/v1/chat/completions"
);
}
}
+150
View File
@@ -695,4 +695,154 @@ mod tests {
assert!(path.ends_with("session.json"));
assert!(path.to_string_lossy().contains(".ironclaw"));
}
#[test]
fn test_session_data_serde_roundtrip_with_auth_provider() {
let original = SessionData {
session_token: "sess_abc123".to_string(),
created_at: Utc::now(),
auth_provider: Some("github".to_string()),
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: SessionData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.session_token, original.session_token);
assert_eq!(deserialized.auth_provider, Some("github".to_string()));
assert_eq!(deserialized.created_at, original.created_at);
}
#[test]
fn test_session_data_serde_roundtrip_without_auth_provider() {
let original = SessionData {
session_token: "sess_xyz789".to_string(),
created_at: Utc::now(),
auth_provider: None,
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: SessionData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.session_token, original.session_token);
assert_eq!(deserialized.auth_provider, None);
}
#[test]
fn test_session_data_missing_auth_provider_defaults_to_none() {
let json = r#"{"session_token":"tok_legacy","created_at":"2025-01-01T00:00:00Z"}"#;
let data: SessionData = serde_json::from_str(json).unwrap();
assert_eq!(data.session_token, "tok_legacy");
assert_eq!(data.auth_provider, None);
}
#[test]
fn test_session_config_default() {
let config = SessionConfig::default();
assert_eq!(config.auth_base_url, "https://private.near.ai");
assert!(config.session_path.ends_with("session.json"));
assert!(config.session_path.to_string_lossy().contains(".ironclaw"));
}
#[tokio::test]
async fn test_new_with_nonexistent_session_file() {
let dir = tempdir().unwrap();
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: dir.path().join("does_not_exist.json"),
};
let manager = SessionManager::new(config);
assert!(!manager.has_token().await);
}
#[tokio::test]
async fn test_set_token_get_token_roundtrip() {
let dir = tempdir().unwrap();
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: dir.path().join("session.json"),
};
let manager = SessionManager::new(config);
manager
.set_token(SecretString::from("my_secret_token"))
.await;
let token = manager.get_token().await.unwrap();
assert_eq!(token.expose_secret(), "my_secret_token");
}
#[tokio::test]
async fn test_has_token_false_then_true() {
let dir = tempdir().unwrap();
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: dir.path().join("session.json"),
};
let manager = SessionManager::new(config);
assert!(!manager.has_token().await);
manager.set_token(SecretString::from("tok_something")).await;
assert!(manager.has_token().await);
}
#[tokio::test]
async fn test_save_session_then_load_in_new_manager() {
let dir = tempdir().unwrap();
let session_path = dir.path().join("session.json");
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: session_path.clone(),
};
let manager = SessionManager::new_async(config.clone()).await;
manager
.save_session("persist_me", Some("google"))
.await
.unwrap();
// Load in a fresh manager
let manager2 = SessionManager::new_async(config).await;
assert!(manager2.has_token().await);
let token = manager2.get_token().await.unwrap();
assert_eq!(token.expose_secret(), "persist_me");
// Verify auth_provider was persisted
let raw: SessionData =
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
assert_eq!(raw.auth_provider, Some("google".to_string()));
}
#[tokio::test]
async fn test_save_session_with_no_auth_provider() {
let dir = tempdir().unwrap();
let session_path = dir.path().join("session.json");
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: session_path.clone(),
};
let manager = SessionManager::new_async(config).await;
manager.save_session("anon_tok", None).await.unwrap();
let raw: SessionData =
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
assert_eq!(raw.session_token, "anon_tok");
assert_eq!(raw.auth_provider, None);
}
#[cfg(unix)]
#[tokio::test]
async fn test_session_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let session_path = dir.path().join("session.json");
let config = SessionConfig {
auth_base_url: "https://example.com".to_string(),
session_path: session_path.clone(),
};
let manager = SessionManager::new_async(config).await;
manager
.save_session("secret_tok", Some("github"))
.await
.unwrap();
let metadata = std::fs::metadata(&session_path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "Session file should have 0600 permissions");
}
}
+104
View File
@@ -266,4 +266,108 @@ mod tests {
let s2 = SecretsCrypto::generate_salt();
assert_ne!(s1, s2, "two generated salts should not be identical");
}
#[test]
fn test_decrypt_truncated_ciphertext() {
let crypto = test_crypto();
// Too short: less than NONCE_SIZE + TAG_SIZE (12 + 16 = 28)
let short = vec![0u8; 10];
let salt = SecretsCrypto::generate_salt();
let result = crypto.decrypt(&short, &salt);
assert!(result.is_err());
match result.unwrap_err() {
crate::secrets::types::SecretError::DecryptionFailed(msg) => {
assert!(msg.contains("too short"));
}
other => panic!("expected DecryptionFailed, got {:?}", other),
}
}
#[test]
fn test_different_master_keys_different_ciphertext() {
let key_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let key_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let crypto_a = SecretsCrypto::new(SecretString::from(key_a.to_string())).unwrap();
let crypto_b = SecretsCrypto::new(SecretString::from(key_b.to_string())).unwrap();
let plaintext = b"shared_secret";
let (enc_a, salt_a) = crypto_a.encrypt(plaintext).unwrap();
let (enc_b, salt_b) = crypto_b.encrypt(plaintext).unwrap();
// Each decrypts its own ciphertext
let dec_a = crypto_a.decrypt(&enc_a, &salt_a).unwrap();
let dec_b = crypto_b.decrypt(&enc_b, &salt_b).unwrap();
assert_eq!(dec_a.expose(), "shared_secret");
assert_eq!(dec_b.expose(), "shared_secret");
// Cross-decryption fails
assert!(crypto_a.decrypt(&enc_b, &salt_b).is_err());
assert!(crypto_b.decrypt(&enc_a, &salt_a).is_err());
}
#[test]
fn test_exact_minimum_key_length() {
// Exactly 32 bytes should work
let key = "a".repeat(super::KEY_SIZE);
assert!(SecretsCrypto::new(SecretString::from(key)).is_ok());
// 31 bytes should fail
let short = "a".repeat(super::KEY_SIZE - 1);
assert!(SecretsCrypto::new(SecretString::from(short)).is_err());
}
#[test]
fn test_longer_master_key_works() {
// Keys longer than 32 bytes are fine (HKDF handles it)
let long_key = "x".repeat(128);
let crypto = SecretsCrypto::new(SecretString::from(long_key)).unwrap();
let plaintext = b"works with long key";
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
assert_eq!(decrypted.expose(), "works with long key");
}
#[test]
fn test_debug_redacts_master_key() {
let crypto = test_crypto();
let debug = format!("{:?}", crypto);
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("0123456789abcdef"));
}
#[test]
fn test_encrypted_output_structure() {
let crypto = test_crypto();
let plaintext = b"hello";
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
// encrypted = nonce (12) + ciphertext (plaintext_len) + tag (16)
assert_eq!(
encrypted.len(),
super::NONCE_SIZE + plaintext.len() + super::TAG_SIZE
);
assert_eq!(salt.len(), super::SALT_SIZE);
}
#[test]
fn test_tampered_nonce_fails() {
let crypto = test_crypto();
let plaintext = b"sensitive";
let (mut encrypted, salt) = crypto.encrypt(plaintext).unwrap();
// Flip a bit in the nonce region (first 12 bytes)
encrypted[0] ^= 0x01;
let result = crypto.decrypt(&encrypted, &salt);
assert!(result.is_err());
}
#[test]
fn test_unicode_plaintext_roundtrip() {
let crypto = test_crypto();
let plaintext = "password: p@$$w0rd! 你好 🔑".as_bytes();
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
assert_eq!(decrypted.expose(), "password: p@$$w0rd! 你好 🔑");
}
}
+222
View File
@@ -281,4 +281,226 @@ mod tests {
assert_eq!(params.name, "key");
assert_eq!(params.provider, Some("stripe".to_string()));
}
#[test]
fn test_create_params_name_lowercased() {
let params = CreateSecretParams::new("SLACK_BOT_TOKEN", "val");
assert_eq!(params.name, "slack_bot_token");
}
#[test]
fn test_create_params_with_expiry() {
use chrono::Utc;
let expiry = Utc::now();
let params = CreateSecretParams::new("key", "val").with_expiry(expiry);
assert_eq!(params.expires_at, Some(expiry));
}
#[test]
fn test_secret_ref_without_provider() {
let r = SecretRef::new("token");
assert_eq!(r.name, "token");
assert!(r.provider.is_none());
}
#[test]
fn test_secret_ref_serde_roundtrip() {
let original = SecretRef::new("api_key").with_provider("openai");
let json = serde_json::to_string(&original).unwrap();
let deserialized: SecretRef = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, original.name);
assert_eq!(deserialized.provider, original.provider);
}
#[test]
fn test_secret_ref_serde_without_provider() {
let original = SecretRef::new("bare_token");
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains("\"provider\":null"));
let deserialized: SecretRef = serde_json::from_str(&json).unwrap();
assert!(deserialized.provider.is_none());
}
#[test]
fn test_credential_location_serde_roundtrip_bearer() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::AuthorizationBearer;
let json = serde_json::to_string(&loc).unwrap();
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
assert!(matches!(back, CredentialLocation::AuthorizationBearer));
}
#[test]
fn test_credential_location_serde_roundtrip_basic() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::AuthorizationBasic {
username: "admin".to_string(),
};
let json = serde_json::to_string(&loc).unwrap();
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
match back {
CredentialLocation::AuthorizationBasic { username } => {
assert_eq!(username, "admin");
}
_ => panic!("expected AuthorizationBasic"),
}
}
#[test]
fn test_credential_location_serde_roundtrip_header() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::Header {
name: "X-Api-Key".to_string(),
prefix: Some("Token".to_string()),
};
let json = serde_json::to_string(&loc).unwrap();
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
match back {
CredentialLocation::Header { name, prefix } => {
assert_eq!(name, "X-Api-Key");
assert_eq!(prefix, Some("Token".to_string()));
}
_ => panic!("expected Header"),
}
}
#[test]
fn test_credential_location_serde_roundtrip_query_param() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::QueryParam {
name: "access_token".to_string(),
};
let json = serde_json::to_string(&loc).unwrap();
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
match back {
CredentialLocation::QueryParam { name } => assert_eq!(name, "access_token"),
_ => panic!("expected QueryParam"),
}
}
#[test]
fn test_credential_location_serde_roundtrip_url_path() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::UrlPath {
placeholder: "{api_key}".to_string(),
};
let json = serde_json::to_string(&loc).unwrap();
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
match back {
CredentialLocation::UrlPath { placeholder } => assert_eq!(placeholder, "{api_key}"),
_ => panic!("expected UrlPath"),
}
}
#[test]
fn test_credential_location_default_is_bearer() {
use crate::secrets::types::CredentialLocation;
let loc = CredentialLocation::default();
assert!(matches!(loc, CredentialLocation::AuthorizationBearer));
}
#[test]
fn test_credential_mapping_bearer_constructor() {
use crate::secrets::types::CredentialMapping;
let m = CredentialMapping::bearer("my_token", "*.example.com");
assert_eq!(m.secret_name, "my_token");
assert!(matches!(
m.location,
crate::secrets::types::CredentialLocation::AuthorizationBearer
));
assert_eq!(m.host_patterns, vec!["*.example.com".to_string()]);
}
#[test]
fn test_credential_mapping_header_constructor() {
use crate::secrets::types::CredentialMapping;
let m = CredentialMapping::header("key", "X-Custom", "api.host.com");
assert_eq!(m.secret_name, "key");
match &m.location {
crate::secrets::types::CredentialLocation::Header { name, prefix } => {
assert_eq!(name, "X-Custom");
assert!(prefix.is_none());
}
_ => panic!("expected Header"),
}
assert_eq!(m.host_patterns, vec!["api.host.com".to_string()]);
}
#[test]
fn test_credential_mapping_serde_roundtrip() {
use crate::secrets::types::CredentialMapping;
let original = CredentialMapping::bearer("tok", "*.api.com");
let json = serde_json::to_string(&original).unwrap();
let back: CredentialMapping = serde_json::from_str(&json).unwrap();
assert_eq!(back.secret_name, "tok");
assert_eq!(back.host_patterns, vec!["*.api.com".to_string()]);
}
#[test]
fn test_decrypted_secret_invalid_utf8() {
let result = DecryptedSecret::from_bytes(vec![0xFF, 0xFE, 0x00]);
assert!(result.is_err());
}
#[test]
fn test_decrypted_secret_empty() {
let secret = DecryptedSecret::from_bytes(Vec::new()).unwrap();
assert!(secret.is_empty());
assert_eq!(secret.len(), 0);
assert_eq!(secret.expose(), "");
}
#[test]
fn test_decrypted_secret_clone() {
let original = DecryptedSecret::from_bytes(b"cloneable".to_vec()).unwrap();
let cloned = original.clone();
assert_eq!(cloned.expose(), "cloneable");
assert_eq!(cloned.len(), original.len());
}
#[test]
fn test_secret_debug_redacts_fields() {
use chrono::Utc;
use uuid::Uuid;
let secret = crate::secrets::types::Secret {
id: Uuid::nil(),
user_id: "user1".to_string(),
name: "test_key".to_string(),
encrypted_value: vec![1, 2, 3],
key_salt: vec![4, 5, 6],
provider: Some("aws".to_string()),
expires_at: None,
last_used_at: None,
usage_count: 5,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let debug = format!("{:?}", secret);
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("[1, 2, 3]"));
assert!(!debug.contains("[4, 5, 6]"));
assert!(debug.contains("test_key"));
}
#[test]
fn test_secret_error_display() {
use crate::secrets::types::SecretError;
assert_eq!(
SecretError::NotFound("foo".into()).to_string(),
"Secret not found: foo"
);
assert_eq!(SecretError::Expired.to_string(), "Secret has expired");
assert_eq!(
SecretError::InvalidMasterKey.to_string(),
"Invalid master key"
);
assert_eq!(
SecretError::InvalidUtf8.to_string(),
"Secret value is not valid UTF-8"
);
assert_eq!(
SecretError::AccessDenied.to_string(),
"Secret access denied for tool"
);
}
}
+4 -2
View File
@@ -3258,8 +3258,10 @@ mod tests {
#[tokio::test]
async fn test_discover_wasm_channels_nonexistent_dir() {
let channels =
discover_wasm_channels(std::path::Path::new("/tmp/ironclaw_nonexistent_dir")).await;
let channels = discover_wasm_channels(
&std::env::temp_dir().join("ironclaw_nonexistent_dir_abcxyz123"),
)
.await;
assert!(channels.is_empty());
}
+368 -8
View File
@@ -1026,24 +1026,384 @@ impl Tool for BuildSoftwareTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::builder::core::*;
#[test]
fn test_language_extensions() {
fn test_language_extension_all_variants() {
assert_eq!(Language::Rust.extension(), "rs");
assert_eq!(Language::Python.extension(), "py");
assert_eq!(Language::TypeScript.extension(), "ts");
assert_eq!(Language::JavaScript.extension(), "js");
assert_eq!(Language::Go.extension(), "go");
assert_eq!(Language::Bash.extension(), "sh");
}
#[test]
fn test_build_commands() {
assert!(Language::Rust.build_command("/tmp/project").is_some());
assert!(Language::Python.build_command("/tmp/project").is_none());
fn test_language_build_command_compiled_returns_some() {
let dir = "/tmp/project";
let rust_cmd = Language::Rust.build_command(dir);
assert!(rust_cmd.is_some());
assert!(rust_cmd.unwrap().contains("cargo build"));
let ts_cmd = Language::TypeScript.build_command(dir);
assert!(ts_cmd.is_some());
assert!(ts_cmd.unwrap().contains("npm run build"));
let go_cmd = Language::Go.build_command(dir);
assert!(go_cmd.is_some());
assert!(go_cmd.unwrap().contains("go build"));
}
#[test]
fn test_software_type_serialization() {
let json = serde_json::to_string(&SoftwareType::WasmTool).unwrap();
assert_eq!(json, "\"wasm_tool\"");
fn test_language_build_command_interpreted_returns_none() {
let dir = "/tmp/project";
assert!(Language::Python.build_command(dir).is_none());
assert!(Language::JavaScript.build_command(dir).is_none());
assert!(Language::Bash.build_command(dir).is_none());
}
#[test]
fn test_language_build_command_includes_project_dir() {
let dir = "/home/user/my_project";
for lang in [Language::Rust, Language::TypeScript, Language::Go] {
let cmd = lang.build_command(dir);
assert!(
cmd.as_ref().unwrap().contains(dir),
"{:?} build command should contain project dir",
lang
);
}
}
#[test]
fn test_language_test_command_all_variants_non_empty() {
let dir = "/tmp/project";
let all_languages = [
Language::Rust,
Language::Python,
Language::TypeScript,
Language::JavaScript,
Language::Go,
Language::Bash,
];
for lang in all_languages {
let cmd = lang.test_command(dir);
assert!(
!cmd.is_empty(),
"{:?} test command should not be empty",
lang
);
assert!(
cmd.contains(dir),
"{:?} test command should contain project dir",
lang
);
}
}
#[test]
fn test_language_test_command_specific_tools() {
let dir = "/tmp/p";
assert!(Language::Rust.test_command(dir).contains("cargo test"));
assert!(Language::Python.test_command(dir).contains("pytest"));
assert!(Language::TypeScript.test_command(dir).contains("npm test"));
assert!(Language::JavaScript.test_command(dir).contains("npm test"));
assert!(Language::Go.test_command(dir).contains("go test"));
assert!(Language::Bash.test_command(dir).contains("shellcheck"));
}
#[test]
fn test_software_type_serde_roundtrip() {
let variants = [
SoftwareType::WasmTool,
SoftwareType::CliBinary,
SoftwareType::Library,
SoftwareType::Script,
SoftwareType::WebService,
];
let expected_strings = [
"\"wasm_tool\"",
"\"cli_binary\"",
"\"library\"",
"\"script\"",
"\"web_service\"",
];
for (variant, expected) in variants.iter().zip(expected_strings.iter()) {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(&json, expected, "serialization mismatch for {:?}", variant);
let deserialized: SoftwareType = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_language_serde_roundtrip() {
let variants = [
Language::Rust,
Language::Python,
Language::TypeScript,
Language::JavaScript,
Language::Go,
Language::Bash,
];
let expected_strings = [
"\"rust\"",
"\"python\"",
"\"type_script\"",
"\"java_script\"",
"\"go\"",
"\"bash\"",
];
for (variant, expected) in variants.iter().zip(expected_strings.iter()) {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(&json, expected, "serialization mismatch for {:?}", variant);
let deserialized: Language = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_build_requirement_serde_roundtrip() {
let req = BuildRequirement {
name: "my_tool".into(),
description: "A tool that does stuff".into(),
software_type: SoftwareType::WasmTool,
language: Language::Rust,
input_spec: Some("JSON object with 'query' field".into()),
output_spec: Some("JSON object with 'result' field".into()),
dependencies: vec!["serde".into(), "reqwest".into()],
capabilities: vec!["http".into(), "workspace".into()],
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, req.name);
assert_eq!(deserialized.description, req.description);
assert_eq!(deserialized.software_type, req.software_type);
assert_eq!(deserialized.language, req.language);
assert_eq!(deserialized.input_spec, req.input_spec);
assert_eq!(deserialized.output_spec, req.output_spec);
assert_eq!(deserialized.dependencies, req.dependencies);
assert_eq!(deserialized.capabilities, req.capabilities);
}
#[test]
fn test_build_requirement_serde_optional_fields_none() {
let req = BuildRequirement {
name: "minimal".into(),
description: "Bare minimum".into(),
software_type: SoftwareType::Script,
language: Language::Bash,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap();
assert!(deserialized.input_spec.is_none());
assert!(deserialized.output_spec.is_none());
assert!(deserialized.dependencies.is_empty());
assert!(deserialized.capabilities.is_empty());
}
#[test]
fn test_builder_config_default_sensible_values() {
let config = BuilderConfig::default();
assert!(config.max_iterations > 0, "max_iterations must be positive");
assert!(!config.timeout.is_zero(), "timeout must be non-zero");
assert!(
config.timeout.as_secs() >= 60,
"timeout should be at least 60 seconds"
);
assert!(config.validate_wasm, "validate_wasm should default to true");
assert!(config.run_tests, "run_tests should default to true");
assert!(config.auto_register, "auto_register should default to true");
assert!(
!config.cleanup_on_failure,
"cleanup_on_failure should default to false for debugging"
);
assert!(
config.wasm_output_dir.is_none(),
"wasm_output_dir should default to None"
);
assert!(
config
.build_dir
.to_string_lossy()
.contains("ironclaw-builds"),
"build_dir should contain 'ironclaw-builds'"
);
}
#[test]
fn test_build_phase_serde_roundtrip() {
let variants = [
BuildPhase::Analyzing,
BuildPhase::Scaffolding,
BuildPhase::Implementing,
BuildPhase::Building,
BuildPhase::Testing,
BuildPhase::Fixing,
BuildPhase::Validating,
BuildPhase::Registering,
BuildPhase::Packaging,
BuildPhase::Complete,
BuildPhase::Failed,
];
for variant in &variants {
let json = serde_json::to_string(variant).unwrap();
let deserialized: BuildPhase = serde_json::from_str(&json).unwrap();
assert_eq!(
&deserialized, variant,
"roundtrip mismatch for {:?}",
variant
);
}
}
#[test]
fn test_build_result_serde_success() {
let result = BuildResult {
build_id: Uuid::nil(),
requirement: BuildRequirement {
name: "test_tool".into(),
description: "test".into(),
software_type: SoftwareType::WasmTool,
language: Language::Rust,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
},
artifact_path: PathBuf::from("/tmp/test.wasm"),
logs: vec![],
success: true,
error: None,
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 3,
validation_warnings: vec![],
tests_passed: 5,
tests_failed: 0,
registered: true,
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: BuildResult = serde_json::from_str(&json).unwrap();
assert!(deserialized.success);
assert!(deserialized.error.is_none());
assert_eq!(deserialized.iterations, 3);
assert_eq!(deserialized.tests_passed, 5);
assert_eq!(deserialized.tests_failed, 0);
assert!(deserialized.registered);
}
#[test]
fn test_build_result_serde_failure() {
let result = BuildResult {
build_id: Uuid::nil(),
requirement: BuildRequirement {
name: "broken".into(),
description: "fails".into(),
software_type: SoftwareType::CliBinary,
language: Language::Go,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
},
artifact_path: PathBuf::from("/tmp/broken"),
logs: vec![],
success: false,
error: Some("compilation error: undefined reference".into()),
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 10,
validation_warnings: vec!["missing export".into()],
tests_passed: 2,
tests_failed: 3,
registered: false,
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: BuildResult = serde_json::from_str(&json).unwrap();
assert!(!deserialized.success);
assert_eq!(
deserialized.error.as_deref(),
Some("compilation error: undefined reference")
);
assert_eq!(deserialized.iterations, 10);
assert_eq!(deserialized.validation_warnings.len(), 1);
assert_eq!(deserialized.tests_passed, 2);
assert_eq!(deserialized.tests_failed, 3);
assert!(!deserialized.registered);
}
#[test]
fn test_build_result_default_fields_from_json() {
// Verify #[serde(default)] fields can be omitted in JSON
let json = serde_json::json!({
"build_id": "00000000-0000-0000-0000-000000000000",
"requirement": {
"name": "x",
"description": "y",
"software_type": "script",
"language": "bash",
"input_spec": null,
"output_spec": null,
"dependencies": [],
"capabilities": []
},
"artifact_path": "/tmp/x.sh",
"logs": [],
"success": true,
"error": null,
"started_at": "2025-01-01T00:00:00Z",
"completed_at": "2025-01-01T00:01:00Z",
"iterations": 1
});
let result: BuildResult = serde_json::from_value(json).unwrap();
assert_eq!(result.validation_warnings, Vec::<String>::new());
assert_eq!(result.tests_passed, 0);
assert_eq!(result.tests_failed, 0);
assert!(!result.registered);
}
#[test]
fn test_build_log_serde_roundtrip() {
let log = BuildLog {
timestamp: Utc::now(),
phase: BuildPhase::Building,
message: "Running cargo build".into(),
details: Some("cargo build --release 2>&1".into()),
};
let json = serde_json::to_string(&log).unwrap();
let deserialized: BuildLog = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.phase, BuildPhase::Building);
assert_eq!(deserialized.message, "Running cargo build");
assert_eq!(
deserialized.details.as_deref(),
Some("cargo build --release 2>&1")
);
}
#[test]
fn test_build_log_serde_details_none() {
let log = BuildLog {
timestamp: Utc::now(),
phase: BuildPhase::Complete,
message: "Done".into(),
details: None,
};
let json = serde_json::to_string(&log).unwrap();
let deserialized: BuildLog = serde_json::from_str(&json).unwrap();
assert!(deserialized.details.is_none());
assert_eq!(deserialized.phase, BuildPhase::Complete);
}
}
+159
View File
@@ -498,4 +498,163 @@ mod tests {
assert_eq!(template.name, "WASM HTTP Tool");
assert!(!template.files.is_empty());
}
#[test]
fn test_render_no_variables() {
let engine = TemplateEngine::new();
let input = "Hello, world! No placeholders here.";
assert_eq!(engine.render(input), input);
}
#[test]
fn test_render_variable_not_found() {
let mut engine = TemplateEngine::new();
engine.set("name", "ironclaw");
let input = "Name: {{name}}, Missing: {{missing}}";
assert_eq!(engine.render(input), "Name: ironclaw, Missing: {{missing}}");
}
#[test]
fn test_render_multiple_replacements_of_same_variable() {
let mut engine = TemplateEngine::new();
engine.set("x", "42");
assert_eq!(engine.render("{{x}} + {{x}} = 2*{{x}}"), "42 + 42 = 2*42");
}
#[test]
fn test_set_overwrites_existing_variable() {
let mut engine = TemplateEngine::new();
engine.set("color", "red");
assert_eq!(engine.render("{{color}}"), "red");
engine.set("color", "blue");
assert_eq!(engine.render("{{color}}"), "blue");
}
#[test]
fn test_render_template_all_files() {
let mut engine = TemplateEngine::new();
engine.set("name", "my_tool");
engine.set("description", "does stuff");
let template = Template::get(TemplateType::CliBinary);
let rendered = engine.render_template(&template);
assert_eq!(rendered.len(), template.files.len());
// Paths should have variables substituted
for (path, _content) in &rendered {
assert!(!path.contains("{{name}}"));
}
// Content should have variables substituted
for (_path, content) in &rendered {
assert!(!content.contains("{{name}}"));
assert!(!content.contains("{{description}}"));
}
}
#[test]
fn test_all_template_types_return_non_empty() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let t = Template::get(tt);
assert!(!t.name.is_empty(), "{:?} has empty name", tt);
assert!(!t.description.is_empty(), "{:?} has empty description", tt);
assert!(!t.files.is_empty(), "{:?} has no files", tt);
for f in &t.files {
assert!(
!f.content.is_empty(),
"{:?} file {:?} has empty content",
tt,
f.path
);
}
}
}
#[test]
fn test_template_type_serde_roundtrip() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let json = serde_json::to_string(&tt).unwrap();
let back: TemplateType = serde_json::from_str(&json).unwrap();
assert_eq!(back, tt, "roundtrip failed for {:?} (json: {})", tt, json);
}
}
#[test]
fn test_each_template_has_at_least_one_required_file() {
let all_types = [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
TemplateType::PythonScript,
TemplateType::BashScript,
];
for tt in all_types {
let t = Template::get(tt);
let required_count = t.files.iter().filter(|f| f.is_required).count();
assert!(required_count >= 1, "{:?} has no required files", tt);
}
}
#[test]
fn test_template_file_extensions() {
// WASM and CLI templates should have Cargo.toml and .rs files
for tt in [
TemplateType::WasmHttpTool,
TemplateType::WasmTransformTool,
TemplateType::WasmComputeTool,
TemplateType::CliBinary,
] {
let t = Template::get(tt);
let paths: Vec<&str> = t.files.iter().map(|f| f.path).collect();
assert!(
paths.iter().any(|p| p.ends_with("Cargo.toml")),
"{:?} missing Cargo.toml",
tt
);
assert!(
paths.iter().any(|p| p.ends_with(".rs")),
"{:?} missing .rs file",
tt
);
}
// Python template should have a .py file
let py = Template::get(TemplateType::PythonScript);
assert!(py.files.iter().any(|f| f.path.ends_with(".py")));
// Bash template should have a .sh file
let bash = Template::get(TemplateType::BashScript);
assert!(bash.files.iter().any(|f| f.path.ends_with(".sh")));
}
#[test]
fn test_python_and_bash_templates_have_name_in_path() {
let py = Template::get(TemplateType::PythonScript);
assert!(
py.files.iter().any(|f| f.path.contains("{{name}}")),
"PythonScript template should have {{{{name}}}} in a file path"
);
let bash = Template::get(TemplateType::BashScript);
assert!(
bash.files.iter().any(|f| f.path.contains("{{name}}")),
"BashScript template should have {{{{name}}}} in a file path"
);
}
}
+159 -1
View File
@@ -315,5 +315,163 @@ mod tests {
);
}
// Note: Full WASM parsing tests would require actual WASM binaries
#[test]
fn test_validate_bytes_invalid_bytes() {
let validator = WasmValidator::new();
let garbage = b"this is not a wasm module at all";
let result = validator.validate_bytes(garbage).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidModule(_)))
);
}
#[test]
fn test_validate_bytes_empty() {
let validator = WasmValidator::new();
let result = validator.validate_bytes(b"").unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::InvalidModule(_)))
);
}
#[test]
fn test_validate_bytes_minimal_wasm_missing_run_export() {
let validator = WasmValidator::new();
// Minimal valid WASM: magic number + version
let minimal_wasm = b"\x00asm\x01\x00\x00\x00";
let result = validator.validate_bytes(minimal_wasm).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::MissingExport(name) if name == "run"))
);
assert_eq!(result.size_bytes, 8);
}
#[test]
fn test_validation_result_is_valid_when_no_errors() {
let result = ValidationResult {
is_valid: true,
errors: vec![],
warnings: vec!["some warning".to_string()],
exports: vec![],
imports: vec![],
size_bytes: 0,
};
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
#[test]
fn test_validation_result_is_invalid_when_errors_present() {
let result = ValidationResult {
is_valid: false,
errors: vec![ValidationError::MissingExport("run".to_string())],
warnings: vec![],
exports: vec![],
imports: vec![],
size_bytes: 0,
};
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
}
#[test]
fn test_validation_error_display() {
let io_err =
ValidationError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
assert!(io_err.to_string().contains("Failed to read WASM file"));
let invalid = ValidationError::InvalidModule("bad magic".to_string());
assert!(invalid.to_string().contains("Invalid WASM module"));
assert!(invalid.to_string().contains("bad magic"));
let missing = ValidationError::MissingExport("run".to_string());
assert!(missing.to_string().contains("Missing required export"));
assert!(missing.to_string().contains("run"));
let sig = ValidationError::InvalidSignature {
name: "run".to_string(),
expected: "() -> i32".to_string(),
actual: "() -> ()".to_string(),
};
assert!(sig.to_string().contains("Invalid export signature"));
assert!(sig.to_string().contains("run"));
let disallowed = ValidationError::DisallowedImport {
module: "evil".to_string(),
name: "hack".to_string(),
};
assert!(disallowed.to_string().contains("disallowed import"));
assert!(disallowed.to_string().contains("evil::hack"));
let too_large = ValidationError::TooLarge {
size: 200,
max: 100,
};
assert!(too_large.to_string().contains("200"));
assert!(too_large.to_string().contains("100"));
let other = ValidationError::Other("something broke".to_string());
assert!(other.to_string().contains("something broke"));
}
#[test]
fn test_export_kind_equality() {
assert_eq!(ExportKind::Function, ExportKind::Function);
assert_eq!(ExportKind::Memory, ExportKind::Memory);
assert_eq!(ExportKind::Table, ExportKind::Table);
assert_eq!(ExportKind::Global, ExportKind::Global);
assert_ne!(ExportKind::Function, ExportKind::Memory);
assert_ne!(ExportKind::Table, ExportKind::Global);
}
#[test]
fn test_import_kind_equality() {
assert_eq!(ImportKind::Function, ImportKind::Function);
assert_eq!(ImportKind::Memory, ImportKind::Memory);
assert_eq!(ImportKind::Table, ImportKind::Table);
assert_eq!(ImportKind::Global, ImportKind::Global);
assert_ne!(ImportKind::Function, ImportKind::Global);
assert_ne!(ImportKind::Memory, ImportKind::Table);
}
#[test]
fn test_validate_bytes_exceeds_max_size() {
let validator = WasmValidator::new().with_max_size(4);
// 8 bytes, over the 4-byte limit
let minimal_wasm = b"\x00asm\x01\x00\x00\x00";
let result = validator.validate_bytes(minimal_wasm).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::TooLarge { size: 8, max: 4 }))
);
}
#[test]
fn test_with_max_size_then_validate_over_limit() {
let validator = WasmValidator::new().with_max_size(16);
let oversized = vec![0u8; 32];
let result = validator.validate_bytes(&oversized).unwrap();
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::TooLarge { size: 32, max: 16 }))
);
}
}
+2 -2
View File
@@ -671,8 +671,8 @@ mod tests {
Arc::new(ToolRegistry::new()),
None,
None,
std::path::PathBuf::from("/tmp/ironclaw-test-tools"),
std::path::PathBuf::from("/tmp/ironclaw-test-channels"),
std::env::temp_dir().join("ironclaw-test-tools"),
std::env::temp_dir().join("ironclaw-test-channels"),
None,
"test".to_string(),
None,
+306
View File
@@ -858,4 +858,310 @@ mod tests {
assert!(url.contains("owner=user"));
assert!(url.contains("state=abc123"));
}
#[test]
fn test_pkce_challenge_s256_is_correct_sha256() {
let pkce = PkceChallenge::generate();
// Recompute the S256 challenge from scratch and compare.
let mut hasher = Sha256::new();
hasher.update(pkce.verifier.as_bytes());
let expected = URL_SAFE_NO_PAD.encode(hasher.finalize());
assert_eq!(pkce.challenge, expected);
}
#[test]
fn test_build_authorization_url_empty_scopes_no_scope_param() {
let url = build_authorization_url(
"https://auth.example.com/authorize",
"client-123",
"http://localhost:9876/callback",
&[],
None,
&HashMap::new(),
);
// With no scopes, the URL must not contain a scope parameter at all.
assert!(!url.contains("scope="));
}
#[test]
fn test_build_authorization_url_special_characters_are_encoded() {
let url = build_authorization_url(
"https://auth.example.com/authorize",
"client id&evil=true",
"http://localhost:9876/call back?x=1",
&[],
None,
&HashMap::new(),
);
// Spaces and ampersands in client_id must be percent-encoded.
assert!(url.contains("client_id=client%20id%26evil%3Dtrue"));
// Spaces and question marks in redirect_uri must be percent-encoded.
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A9876%2Fcall%20back%3Fx%3D1"));
}
#[test]
fn test_protected_resource_metadata_serde_roundtrip_full() {
let meta = ProtectedResourceMetadata {
resource: "https://mcp.example.com".to_string(),
authorization_servers: vec![
"https://auth1.example.com".to_string(),
"https://auth2.example.com".to_string(),
],
scopes_supported: vec!["read".to_string(), "write".to_string()],
};
let json = serde_json::to_string(&meta).unwrap();
let deserialized: ProtectedResourceMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.resource, meta.resource);
assert_eq!(
deserialized.authorization_servers,
meta.authorization_servers
);
assert_eq!(deserialized.scopes_supported, meta.scopes_supported);
}
#[test]
fn test_protected_resource_metadata_serde_roundtrip_minimal() {
// Only required field, optional vecs should default to empty.
let json = r#"{"resource": "https://mcp.example.com"}"#;
let meta: ProtectedResourceMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.resource, "https://mcp.example.com");
assert!(meta.authorization_servers.is_empty());
assert!(meta.scopes_supported.is_empty());
}
#[test]
fn test_authorization_server_metadata_serde_roundtrip_all_fields() {
let meta = AuthorizationServerMetadata {
issuer: "https://auth.example.com".to_string(),
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
token_endpoint: "https://auth.example.com/token".to_string(),
registration_endpoint: Some("https://auth.example.com/register".to_string()),
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
code_challenge_methods_supported: vec!["S256".to_string()],
scopes_supported: vec!["openid".to_string(), "profile".to_string()],
};
let json = serde_json::to_string(&meta).unwrap();
let rt: AuthorizationServerMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(rt.issuer, meta.issuer);
assert_eq!(rt.authorization_endpoint, meta.authorization_endpoint);
assert_eq!(rt.token_endpoint, meta.token_endpoint);
assert_eq!(rt.registration_endpoint, meta.registration_endpoint);
assert_eq!(rt.response_types_supported, meta.response_types_supported);
assert_eq!(rt.grant_types_supported, meta.grant_types_supported);
assert_eq!(
rt.code_challenge_methods_supported,
meta.code_challenge_methods_supported
);
assert_eq!(rt.scopes_supported, meta.scopes_supported);
}
#[test]
fn test_authorization_server_metadata_serde_without_registration() {
let json = r#"{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token"
}"#;
let meta: AuthorizationServerMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.issuer, "https://auth.example.com");
assert!(meta.registration_endpoint.is_none());
assert!(meta.response_types_supported.is_empty());
assert!(meta.grant_types_supported.is_empty());
}
#[test]
fn test_client_registration_request_serialization() {
let req = ClientRegistrationRequest {
client_name: "IronClaw".to_string(),
redirect_uris: vec!["http://localhost:9876/callback".to_string()],
grant_types: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
token_endpoint_auth_method: "none".to_string(),
};
let value: serde_json::Value = serde_json::to_value(&req).unwrap();
assert_eq!(value["client_name"], "IronClaw");
assert_eq!(value["redirect_uris"][0], "http://localhost:9876/callback");
assert_eq!(value["grant_types"][0], "authorization_code");
assert_eq!(value["grant_types"][1], "refresh_token");
assert_eq!(value["response_types"][0], "code");
assert_eq!(value["token_endpoint_auth_method"], "none");
}
#[test]
fn test_client_registration_response_deserialization_full() {
let json = r#"{
"client_id": "abc-123",
"client_secret": "s3cret",
"client_secret_expires_at": 1700000000,
"registration_access_token": "reg-tok",
"registration_client_uri": "https://auth.example.com/register/abc-123"
}"#;
let resp: ClientRegistrationResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.client_id, "abc-123");
assert_eq!(resp.client_secret.as_deref(), Some("s3cret"));
assert_eq!(resp.client_secret_expires_at, Some(1700000000));
assert_eq!(resp.registration_access_token.as_deref(), Some("reg-tok"));
assert_eq!(
resp.registration_client_uri.as_deref(),
Some("https://auth.example.com/register/abc-123")
);
}
#[test]
fn test_client_registration_response_deserialization_minimal() {
let json = r#"{"client_id": "xyz-789"}"#;
let resp: ClientRegistrationResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.client_id, "xyz-789");
assert!(resp.client_secret.is_none());
assert!(resp.client_secret_expires_at.is_none());
assert!(resp.registration_access_token.is_none());
assert!(resp.registration_client_uri.is_none());
}
#[test]
fn test_access_token_construction() {
let token = AccessToken {
access_token: "at-abc".to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(3600),
refresh_token: Some("rt-xyz".to_string()),
scope: Some("read write".to_string()),
};
assert_eq!(token.access_token, "at-abc");
assert_eq!(token.token_type, "Bearer");
assert_eq!(token.expires_in, Some(3600));
assert_eq!(token.refresh_token.as_deref(), Some("rt-xyz"));
assert_eq!(token.scope.as_deref(), Some("read write"));
// Also test with no optional fields.
let minimal = AccessToken {
access_token: "tok".to_string(),
token_type: "bearer".to_string(),
expires_in: None,
refresh_token: None,
scope: None,
};
assert!(minimal.expires_in.is_none());
assert!(minimal.refresh_token.is_none());
assert!(minimal.scope.is_none());
}
#[test]
fn test_token_response_to_access_token_pattern() {
// TokenResponse is private, but we can test the conversion pattern
// by deserializing JSON the same way exchange_code_for_token does.
let json = r#"{
"access_token": "eyJ-token",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "refresh-me",
"scope": "openid profile"
}"#;
// Deserialize via the same struct path the production code uses.
let resp: serde_json::Value = serde_json::from_str(json).unwrap();
let token = AccessToken {
access_token: resp["access_token"].as_str().unwrap().to_string(),
token_type: resp["token_type"].as_str().unwrap().to_string(),
expires_in: resp["expires_in"].as_u64(),
refresh_token: resp["refresh_token"].as_str().map(String::from),
scope: resp["scope"].as_str().map(String::from),
};
assert_eq!(token.access_token, "eyJ-token");
assert_eq!(token.token_type, "Bearer");
assert_eq!(token.expires_in, Some(7200));
assert_eq!(token.refresh_token.as_deref(), Some("refresh-me"));
assert_eq!(token.scope.as_deref(), Some("openid profile"));
// Without optional fields.
let minimal_json = r#"{"access_token": "tok", "token_type": "bearer"}"#;
let resp: serde_json::Value = serde_json::from_str(minimal_json).unwrap();
let token = AccessToken {
access_token: resp["access_token"].as_str().unwrap().to_string(),
token_type: resp["token_type"].as_str().unwrap().to_string(),
expires_in: resp["expires_in"].as_u64(),
refresh_token: resp["refresh_token"].as_str().map(String::from),
scope: resp["scope"].as_str().map(String::from),
};
assert!(token.expires_in.is_none());
assert!(token.refresh_token.is_none());
assert!(token.scope.is_none());
}
#[test]
fn test_auth_error_display_strings() {
let cases: Vec<(AuthError, &str)> = vec![
(
AuthError::NotSupported,
"Server does not support OAuth authorization",
),
(
AuthError::DiscoveryFailed("timeout".to_string()),
"Failed to discover authorization endpoints: timeout",
),
(
AuthError::AuthorizationDenied,
"Authorization denied by user",
),
(
AuthError::TokenExchangeFailed("bad code".to_string()),
"Token exchange failed: bad code",
),
(
AuthError::RefreshFailed("expired".to_string()),
"Token expired and refresh failed: expired",
),
(AuthError::NoToken, "No access token available"),
(
AuthError::Timeout,
"Timeout waiting for authorization callback",
),
(
AuthError::PortUnavailable,
"Could not bind to callback port",
),
(
AuthError::Http("connection refused".to_string()),
"HTTP error: connection refused",
),
(
AuthError::Secrets("decrypt failed".to_string()),
"Secrets error: decrypt failed",
),
];
for (error, expected) in cases {
let display = error.to_string();
assert_eq!(
display, expected,
"AuthError display mismatch for {:?}",
error
);
}
}
}
+157
View File
@@ -583,4 +583,161 @@ mod tests {
assert!(client.session_manager.is_none());
assert!(client.secrets.is_none());
}
#[test]
fn test_extract_server_name_with_port() {
assert_eq!(
extract_server_name("http://example.com:3000"),
"example_com"
);
}
#[test]
fn test_extract_server_name_with_path() {
assert_eq!(
extract_server_name("http://api.server.io/v2/mcp"),
"api_server_io"
);
}
#[test]
fn test_extract_server_name_with_query_params() {
assert_eq!(
extract_server_name("http://mcp.example.com/endpoint?token=abc&v=1"),
"mcp_example_com"
);
}
#[test]
fn test_extract_server_name_https() {
assert_eq!(
extract_server_name("https://secure.mcp.dev"),
"secure_mcp_dev"
);
}
#[test]
fn test_extract_server_name_ip_address() {
assert_eq!(
extract_server_name("http://192.168.1.100:9090/mcp"),
"192_168_1_100"
);
}
#[test]
fn test_new_defaults() {
let client = McpClient::new("http://localhost:9999");
assert_eq!(client.server_url(), "http://localhost:9999");
assert_eq!(client.server_name(), "localhost");
assert!(client.session_manager.is_none());
assert!(client.secrets.is_none());
assert_eq!(client.user_id, "default");
}
#[test]
fn test_new_with_name_uses_custom_name() {
let client = McpClient::new_with_name("my-server", "http://localhost:8080");
assert_eq!(client.server_name(), "my-server");
assert_eq!(client.server_url(), "http://localhost:8080");
assert_eq!(client.user_id, "default");
assert!(client.session_manager.is_none());
assert!(client.secrets.is_none());
}
#[test]
fn test_server_name_accessor() {
let client = McpClient::new("https://tools.example.org/mcp");
assert_eq!(client.server_name(), "tools_example_org");
}
#[test]
fn test_server_url_accessor() {
let url = "https://tools.example.org/mcp?v=2";
let client = McpClient::new(url);
assert_eq!(client.server_url(), url);
}
#[test]
fn test_clone_preserves_fields() {
let client = McpClient::new_with_name("cloned-server", "http://localhost:5555");
// Bump the request ID a few times
client.next_request_id();
client.next_request_id();
let cloned = client.clone();
assert_eq!(cloned.server_url(), "http://localhost:5555");
assert_eq!(cloned.server_name(), "cloned-server");
assert_eq!(cloned.user_id, "default");
// The atomic counter value is copied
assert_eq!(cloned.next_id.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_clone_resets_tools_cache() {
let client = McpClient::new("http://localhost:5555");
// The clone implementation resets tools_cache to None
let cloned = client.clone();
let cache = cloned.tools_cache.read().await;
assert!(cache.is_none());
}
#[test]
fn test_next_request_id_monotonically_increasing() {
let client = McpClient::new("http://localhost:1234");
let id1 = client.next_request_id();
let id2 = client.next_request_id();
let id3 = client.next_request_id();
assert_eq!(id1, 1);
assert_eq!(id2, 2);
assert_eq!(id3, 3);
}
#[test]
fn test_mcp_tool_requires_approval_destructive() {
use crate::tools::mcp::protocol::{McpTool, McpToolAnnotations};
let tool = McpTool {
name: "delete_all".to_string(),
description: "Deletes everything".to_string(),
input_schema: serde_json::json!({"type": "object"}),
annotations: Some(McpToolAnnotations {
destructive_hint: true,
side_effects_hint: false,
read_only_hint: false,
execution_time_hint: None,
}),
};
assert!(tool.requires_approval());
}
#[test]
fn test_mcp_tool_no_approval_when_not_destructive() {
use crate::tools::mcp::protocol::{McpTool, McpToolAnnotations};
let tool = McpTool {
name: "read_data".to_string(),
description: "Reads data".to_string(),
input_schema: serde_json::json!({"type": "object"}),
annotations: Some(McpToolAnnotations {
destructive_hint: false,
side_effects_hint: true,
read_only_hint: false,
execution_time_hint: None,
}),
};
assert!(!tool.requires_approval());
}
#[test]
fn test_mcp_tool_no_approval_when_no_annotations() {
use crate::tools::mcp::protocol::McpTool;
let tool = McpTool {
name: "simple_tool".to_string(),
description: "A simple tool".to_string(),
input_schema: serde_json::json!({"type": "object"}),
annotations: None,
};
assert!(!tool.requires_approval());
}
}
+273
View File
@@ -352,6 +352,279 @@ mod tests {
assert!(tool.input_schema["properties"].is_object());
}
#[test]
fn test_initialize_request() {
let req = McpRequest::initialize(42);
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.id, 42);
assert_eq!(req.method, "initialize");
let params = req.params.expect("initialize must have params");
assert_eq!(params["protocolVersion"], PROTOCOL_VERSION);
assert!(params["capabilities"].is_object());
assert!(params["capabilities"]["roots"].is_object());
assert!(params["capabilities"]["sampling"].is_object());
assert_eq!(params["clientInfo"]["name"], "ironclaw");
assert!(params["clientInfo"]["version"].is_string());
}
#[test]
fn test_initialized_notification() {
let req = McpRequest::initialized_notification();
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.method, "notifications/initialized");
assert!(req.params.is_none());
}
#[test]
fn test_call_tool_request() {
let args = serde_json::json!({"query": "rust async"});
let req = McpRequest::call_tool(7, "search", args.clone());
assert_eq!(req.id, 7);
assert_eq!(req.method, "tools/call");
let params = req.params.expect("call_tool must have params");
assert_eq!(params["name"], "search");
assert_eq!(params["arguments"], args);
}
#[test]
fn test_mcp_response_deserialize_success() {
let json = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "tools": [] }
});
let resp: McpResponse = serde_json::from_value(json).expect("deserialize");
assert_eq!(resp.id, 1);
assert!(resp.result.is_some());
assert!(resp.error.is_none());
}
#[test]
fn test_mcp_response_deserialize_error() {
let json = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32601,
"message": "Method not found"
}
});
let resp: McpResponse = serde_json::from_value(json).expect("deserialize");
assert!(resp.result.is_none());
let err = resp.error.expect("should have error");
assert_eq!(err.code, -32601);
assert_eq!(err.message, "Method not found");
assert!(err.data.is_none());
}
#[test]
fn test_mcp_error_roundtrip() {
let err = McpError {
code: -32600,
message: "Invalid Request".to_string(),
data: Some(serde_json::json!({"detail": "missing field"})),
};
let serialized = serde_json::to_string(&err).expect("serialize");
let deserialized: McpError = serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized.code, err.code);
assert_eq!(deserialized.message, err.message);
assert_eq!(deserialized.data, err.data);
}
#[test]
fn test_initialize_result_full() {
let json = serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true, "listChanged": false },
"prompts": { "listChanged": true },
"logging": {}
},
"serverInfo": {
"name": "test-server",
"version": "1.2.3"
},
"instructions": "Use this server for testing."
});
let result: InitializeResult = serde_json::from_value(json).expect("deserialize");
assert_eq!(result.protocol_version.as_deref(), Some("2024-11-05"));
let tools_cap = result.capabilities.tools.expect("has tools capability");
assert!(tools_cap.list_changed);
let res_cap = result
.capabilities
.resources
.expect("has resources capability");
assert!(res_cap.subscribe);
assert!(!res_cap.list_changed);
let prompts_cap = result.capabilities.prompts.expect("has prompts capability");
assert!(prompts_cap.list_changed);
assert!(result.capabilities.logging.is_some());
let info = result.server_info.expect("has server info");
assert_eq!(info.name, "test-server");
assert_eq!(info.version.as_deref(), Some("1.2.3"));
assert_eq!(
result.instructions.as_deref(),
Some("Use this server for testing.")
);
}
#[test]
fn test_content_block_as_text() {
let text_block = ContentBlock::Text {
text: "hello".to_string(),
};
assert_eq!(text_block.as_text(), Some("hello"));
let image_block = ContentBlock::Image {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
};
assert!(image_block.as_text().is_none());
let resource_block = ContentBlock::Resource {
uri: "file:///tmp/a.txt".to_string(),
mime_type: Some("text/plain".to_string()),
text: Some("content".to_string()),
};
assert!(resource_block.as_text().is_none());
}
#[test]
fn test_content_block_serde_tagged_union() {
let text_block = ContentBlock::Text {
text: "hi".to_string(),
};
let json = serde_json::to_value(&text_block).expect("serialize");
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hi");
let image_block = ContentBlock::Image {
data: "abc".to_string(),
mime_type: "image/jpeg".to_string(),
};
let json = serde_json::to_value(&image_block).expect("serialize");
assert_eq!(json["type"], "image");
assert_eq!(json["data"], "abc");
assert_eq!(json["mime_type"], "image/jpeg");
let resource_block = ContentBlock::Resource {
uri: "file:///x".to_string(),
mime_type: None,
text: None,
};
let json = serde_json::to_value(&resource_block).expect("serialize");
assert_eq!(json["type"], "resource");
assert_eq!(json["uri"], "file:///x");
}
#[test]
fn test_call_tool_result_is_error() {
let success: CallToolResult = serde_json::from_value(serde_json::json!({
"content": [{"type": "text", "text": "done"}],
"is_error": false
}))
.expect("deserialize");
assert!(!success.is_error);
assert_eq!(success.content.len(), 1);
let failure: CallToolResult = serde_json::from_value(serde_json::json!({
"content": [{"type": "text", "text": "boom"}],
"is_error": true
}))
.expect("deserialize");
assert!(failure.is_error);
}
#[test]
fn test_call_tool_result_is_error_defaults_false() {
let result: CallToolResult = serde_json::from_value(serde_json::json!({
"content": []
}))
.expect("deserialize");
assert!(!result.is_error);
}
#[test]
fn test_requires_approval_with_destructive_hint() {
let tool = McpTool {
name: "delete_all".to_string(),
description: "Deletes everything".to_string(),
input_schema: default_input_schema(),
annotations: Some(McpToolAnnotations {
destructive_hint: true,
..Default::default()
}),
};
assert!(tool.requires_approval());
}
#[test]
fn test_requires_approval_without_destructive_hint() {
let tool = McpTool {
name: "read_file".to_string(),
description: "Reads a file".to_string(),
input_schema: default_input_schema(),
annotations: Some(McpToolAnnotations {
destructive_hint: false,
read_only_hint: true,
..Default::default()
}),
};
assert!(!tool.requires_approval());
}
#[test]
fn test_requires_approval_no_annotations() {
let tool = McpTool {
name: "ping".to_string(),
description: "Ping".to_string(),
input_schema: default_input_schema(),
annotations: None,
};
assert!(!tool.requires_approval());
}
#[test]
fn test_mcp_tool_annotations_defaults() {
let annotations = McpToolAnnotations::default();
assert!(!annotations.destructive_hint);
assert!(!annotations.side_effects_hint);
assert!(!annotations.read_only_hint);
assert!(annotations.execution_time_hint.is_none());
}
#[test]
fn test_execution_time_hint_serde() {
// Fast
let json = serde_json::json!("fast");
let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize fast");
assert_eq!(hint, ExecutionTimeHint::Fast);
let serialized = serde_json::to_value(hint).expect("serialize fast");
assert_eq!(serialized, "fast");
// Medium
let json = serde_json::json!("medium");
let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize medium");
assert_eq!(hint, ExecutionTimeHint::Medium);
let serialized = serde_json::to_value(hint).expect("serialize medium");
assert_eq!(serialized, "medium");
// Slow
let json = serde_json::json!("slow");
let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize slow");
assert_eq!(hint, ExecutionTimeHint::Slow);
let serialized = serde_json::to_value(hint).expect("serialize slow");
assert_eq!(serialized, "slow");
}
#[test]
fn test_mcp_tool_roundtrip_preserves_schema() {
// Simulate what list_tools returns from a real MCP server
+104
View File
@@ -283,4 +283,108 @@ mod tests {
assert!(servers.contains(&"notion".to_string()));
assert!(servers.contains(&"github".to_string()));
}
#[test]
fn test_update_session_id_none_leaves_id_unchanged() {
let mut session = McpSession::new("https://mcp.example.com");
session.session_id = Some("existing-id".to_string());
session.update_session_id(None);
assert_eq!(session.session_id, Some("existing-id".to_string()));
}
#[test]
fn test_touch_updates_last_activity() {
let mut session = McpSession::new("https://mcp.example.com");
// Push last_activity into the past so we can observe the change.
session.last_activity = std::time::Instant::now() - std::time::Duration::from_secs(60);
let before = session.last_activity;
session.touch();
assert!(session.last_activity > before);
}
#[test]
fn test_with_idle_timeout() {
let manager = McpSessionManager::with_idle_timeout(42);
assert_eq!(manager.max_idle_secs, 42);
}
#[tokio::test]
async fn test_get_session_id_nonexistent_returns_none() {
let manager = McpSessionManager::new();
assert!(manager.get_session_id("ghost").await.is_none());
}
#[tokio::test]
async fn test_update_session_id_nonexistent_is_noop() {
let manager = McpSessionManager::new();
// Should not panic or create a session.
manager
.update_session_id("ghost", Some("id".to_string()))
.await;
assert!(manager.active_servers().await.is_empty());
}
#[tokio::test]
async fn test_mark_initialized_nonexistent_is_noop() {
let manager = McpSessionManager::new();
manager.mark_initialized("ghost").await;
assert!(manager.active_servers().await.is_empty());
}
#[tokio::test]
async fn test_touch_nonexistent_is_noop() {
let manager = McpSessionManager::new();
manager.touch("ghost").await;
assert!(manager.active_servers().await.is_empty());
}
#[tokio::test]
async fn test_cleanup_stale_removes_only_stale() {
// Use a 5-second idle timeout so we can fake staleness easily.
let manager = McpSessionManager::with_idle_timeout(5);
manager
.get_or_create("fresh", "https://fresh.example.com")
.await;
manager
.get_or_create("stale1", "https://stale1.example.com")
.await;
manager
.get_or_create("stale2", "https://stale2.example.com")
.await;
// Push the two stale sessions into the past.
{
let mut sessions = manager.sessions.write().await;
let past = std::time::Instant::now() - std::time::Duration::from_secs(60);
sessions.get_mut("stale1").unwrap().last_activity = past;
sessions.get_mut("stale2").unwrap().last_activity = past;
}
let removed = manager.cleanup_stale().await;
assert_eq!(removed, 2);
let remaining = manager.active_servers().await;
assert_eq!(remaining.len(), 1);
assert!(remaining.contains(&"fresh".to_string()));
}
#[tokio::test]
async fn test_terminate_nonexistent_is_noop() {
let manager = McpSessionManager::new();
// Should not panic.
manager.terminate("ghost").await;
assert!(manager.active_servers().await.is_empty());
}
#[test]
fn test_default_trait_impl() {
let manager = McpSessionManager::default();
// Default should match new(), which uses 1800s idle timeout.
assert_eq!(manager.max_idle_secs, 1800);
}
}