diff --git a/Cargo.lock b/Cargo.lock index c2cf8663f..4e583e456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,7 +4307,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.52.17" +version = "0.52.18" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index aa1c9d649..a2ff8b694 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.52.16" +version = "0.52.18" dependencies = [ "env_logger", "log", diff --git a/src/core/all.rs b/src/core/all.rs index d529db0f7..f65942e4c 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -474,4 +474,157 @@ mod tests { let err = validate_registry(®istered, &declared).expect_err("expected duplicate input"); assert!(err.contains("duplicate required input `use_cache` in `doctor.models`")); } + + #[test] + fn validate_registry_accepts_valid_registry() { + let declared = vec![ + schema("ns1", "fn1", vec![]), + schema("ns1", "fn2", vec![]), + schema("ns2", "fn1", vec![]), + ]; + let registered = declared + .iter() + .map(|s| RegisteredController { + schema: s.clone(), + handler: noop_handler, + }) + .collect::>(); + assert!(validate_registry(®istered, &declared).is_ok()); + } + + #[test] + fn rpc_method_name_formats_correctly() { + let s = schema("memory", "doc_put", vec![]); + assert_eq!(rpc_method_name(&s), "openhuman.memory_doc_put"); + } + + #[test] + fn registered_controller_rpc_method_name() { + let s = schema("billing", "get_balance", vec![]); + let rc = RegisteredController { + schema: s, + handler: noop_handler, + }; + assert_eq!(rc.rpc_method_name(), "openhuman.billing_get_balance"); + } + + #[test] + fn namespace_description_known_namespaces() { + assert!(namespace_description("memory").is_some()); + assert!(namespace_description("billing").is_some()); + assert!(namespace_description("config").is_some()); + assert!(namespace_description("health").is_some()); + assert!(namespace_description("voice").is_some()); + assert!(namespace_description("webhooks").is_some()); + } + + #[test] + fn namespace_description_unknown_returns_none() { + assert!(namespace_description("nonexistent_xyz").is_none()); + } + + #[test] + fn validate_params_accepts_valid_params() { + let s = schema( + "test", + "fn", + vec![FieldSchema { + name: "key", + ty: TypeSchema::String, + comment: "a key", + required: true, + }], + ); + let mut params = Map::new(); + params.insert("key".into(), Value::String("value".into())); + assert!(validate_params(&s, ¶ms).is_ok()); + } + + #[test] + fn validate_params_rejects_missing_required() { + let s = schema( + "test", + "fn", + vec![FieldSchema { + name: "key", + ty: TypeSchema::String, + comment: "a key", + required: true, + }], + ); + let params = Map::new(); + let err = validate_params(&s, ¶ms).unwrap_err(); + assert!(err.contains("missing required param 'key'")); + } + + #[test] + fn validate_params_rejects_unknown_param() { + let s = schema("test", "fn", vec![]); + let mut params = Map::new(); + params.insert("unknown".into(), Value::Null); + let err = validate_params(&s, ¶ms).unwrap_err(); + assert!(err.contains("unknown param 'unknown'")); + } + + #[test] + fn validate_params_accepts_empty_for_no_required() { + let s = schema("test", "fn", vec![]); + assert!(validate_params(&s, &Map::new()).is_ok()); + } + + #[test] + fn all_registered_controllers_is_nonempty() { + let controllers = all_registered_controllers(); + assert!( + controllers.len() > 50, + "expected many controllers, got {}", + controllers.len() + ); + } + + #[test] + fn all_controller_schemas_matches_registered_count() { + let schemas = all_controller_schemas(); + let controllers = all_registered_controllers(); + assert_eq!(schemas.len(), controllers.len()); + } + + #[test] + fn schema_for_rpc_method_finds_known_method() { + let schema = schema_for_rpc_method("openhuman.health_snapshot"); + assert!(schema.is_some(), "health.snapshot should be findable"); + let s = schema.unwrap(); + assert_eq!(s.namespace, "health"); + assert_eq!(s.function, "snapshot"); + } + + #[test] + fn schema_for_rpc_method_returns_none_for_unknown() { + assert!(schema_for_rpc_method("openhuman.nonexistent_method_xyz").is_none()); + } + + #[test] + fn rpc_method_from_parts_finds_known() { + let method = rpc_method_from_parts("health", "snapshot"); + assert_eq!(method.as_deref(), Some("openhuman.health_snapshot")); + } + + #[test] + fn rpc_method_from_parts_returns_none_for_unknown() { + assert!(rpc_method_from_parts("fake", "method").is_none()); + } + + #[test] + fn no_duplicate_rpc_methods_in_registry() { + let controllers = all_registered_controllers(); + let mut methods: Vec = controllers.iter().map(|c| c.rpc_method_name()).collect(); + let original_len = methods.len(); + methods.sort(); + methods.dedup(); + assert_eq!( + methods.len(), + original_len, + "duplicate RPC methods found in registry" + ); + } } diff --git a/src/openhuman/accessibility/keys.rs b/src/openhuman/accessibility/keys.rs index 0ba3c0fda..3d35b79c2 100644 --- a/src/openhuman/accessibility/keys.rs +++ b/src/openhuman/accessibility/keys.rs @@ -103,3 +103,32 @@ const KCG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE: i32 = 0; const KVK_TAB: u16 = 48; #[cfg(target_os = "macos")] const KVK_ESCAPE: u16 = 53; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_tab_key_down_returns_bool() { + // Just verify it doesn't panic and returns a bool. + let _result: bool = is_tab_key_down(); + } + + #[test] + fn is_escape_key_down_returns_bool() { + let _result: bool = is_escape_key_down(); + } + + #[cfg(target_os = "macos")] + #[test] + fn input_monitoring_recheck_interval_is_positive() { + assert!(INPUT_MONITORING_RECHECK_MS > 0); + } + + #[cfg(target_os = "macos")] + #[test] + fn kvk_constants_are_correct() { + assert_eq!(KVK_TAB, 48); + assert_eq!(KVK_ESCAPE, 53); + } +} diff --git a/src/openhuman/accessibility/terminal.rs b/src/openhuman/accessibility/terminal.rs index 7516891ae..be0af91cb 100644 --- a/src/openhuman/accessibility/terminal.rs +++ b/src/openhuman/accessibility/terminal.rs @@ -80,3 +80,108 @@ pub fn extract_terminal_input_context(text: &str) -> String { } fallback } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_text_role_accepts_known_roles() { + assert!(is_text_role(Some("AXTextArea"))); + assert!(is_text_role(Some("AXTextField"))); + assert!(is_text_role(Some("AXSearchField"))); + assert!(is_text_role(Some("AXComboBox"))); + assert!(is_text_role(Some("AXEditableText"))); + } + + #[test] + fn is_text_role_rejects_other_roles() { + assert!(!is_text_role(Some("AXButton"))); + assert!(!is_text_role(Some("AXImage"))); + assert!(!is_text_role(None)); + assert!(!is_text_role(Some(""))); + } + + #[test] + fn is_terminal_app_detects_known_terminals() { + assert!(is_terminal_app(Some("iTerm2"))); + assert!(is_terminal_app(Some("Terminal"))); + assert!(is_terminal_app(Some("WezTerm"))); + assert!(is_terminal_app(Some("Alacritty"))); + assert!(is_terminal_app(Some("kitty"))); + assert!(is_terminal_app(Some("Warp"))); + assert!(is_terminal_app(Some("Ghostty"))); + } + + #[test] + fn is_terminal_app_rejects_non_terminals() { + assert!(!is_terminal_app(Some("Safari"))); + assert!(!is_terminal_app(Some("Slack"))); + assert!(!is_terminal_app(None)); + assert!(!is_terminal_app(Some(""))); + } + + #[test] + fn looks_like_terminal_buffer_detects_shell_prompts() { + let buffer = "line1\nline2\nline3\nline4\n$ cargo build\nCompiling...\n"; + assert!(looks_like_terminal_buffer(buffer)); + } + + #[test] + fn looks_like_terminal_buffer_rejects_short_text() { + assert!(!looks_like_terminal_buffer("hello")); + assert!(!looks_like_terminal_buffer("$ cmd")); + } + + #[test] + fn looks_like_terminal_buffer_detects_git_status() { + let buffer = "line1\nline2\nline3\nline4\ngit status\nOn branch main\n"; + assert!(looks_like_terminal_buffer(buffer)); + } + + #[test] + fn extract_terminal_input_context_finds_prompt_line() { + let text = "old output\n$ ls -la\ntotal 42\nfile1.txt\nfile2.txt\n"; + let ctx = extract_terminal_input_context(text); + assert!(ctx.contains("$ ls"), "expected prompt line, got: {ctx}"); + } + + #[test] + fn extract_terminal_input_context_skips_noise() { + let text = "actual content\n• bullet point\n└── tree branch\n│ pipe\n"; + let ctx = extract_terminal_input_context(text); + assert_eq!(ctx, "actual content"); + } + + #[test] + fn extract_terminal_input_context_empty_returns_empty() { + assert!(extract_terminal_input_context("").is_empty()); + } + + #[test] + fn extract_terminal_input_context_all_noise_returns_empty() { + let text = "\n\n\n"; + assert!(extract_terminal_input_context(text).is_empty()); + } + + #[test] + fn terminal_names_is_nonempty() { + assert!(!TERMINAL_NAMES.is_empty()); + } + + #[test] + fn is_terminal_noise_line_detects_noise() { + assert!(is_terminal_noise_line("")); + assert!(is_terminal_noise_line(" ")); + assert!(is_terminal_noise_line("• item")); + assert!(is_terminal_noise_line("└── branch")); + assert!(is_terminal_noise_line("─────")); + assert!(is_terminal_noise_line("│ pipe")); + } + + #[test] + fn is_terminal_noise_line_passes_normal_text() { + assert!(!is_terminal_noise_line("hello world")); + assert!(!is_terminal_noise_line("$ command")); + } +} diff --git a/src/openhuman/channels/runtime/supervision.rs b/src/openhuman/channels/runtime/supervision.rs index ab36a1f12..0667237c0 100644 --- a/src/openhuman/channels/runtime/supervision.rs +++ b/src/openhuman/channels/runtime/supervision.rs @@ -83,3 +83,39 @@ pub(crate) fn compute_max_in_flight_messages(channel_count: usize) -> usize { CHANNEL_MAX_IN_FLIGHT_MESSAGES, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_max_in_flight_messages_zero_channels() { + let result = compute_max_in_flight_messages(0); + assert_eq!(result, CHANNEL_MIN_IN_FLIGHT_MESSAGES); + } + + #[test] + fn compute_max_in_flight_messages_one_channel() { + let result = compute_max_in_flight_messages(1); + assert!(result >= CHANNEL_MIN_IN_FLIGHT_MESSAGES); + assert!(result <= CHANNEL_MAX_IN_FLIGHT_MESSAGES); + } + + #[test] + fn compute_max_in_flight_messages_many_channels() { + let result = compute_max_in_flight_messages(100); + assert_eq!(result, CHANNEL_MAX_IN_FLIGHT_MESSAGES); + } + + #[test] + fn compute_max_in_flight_messages_clamps_to_min() { + let result = compute_max_in_flight_messages(0); + assert!(result >= CHANNEL_MIN_IN_FLIGHT_MESSAGES); + } + + #[test] + fn compute_max_in_flight_messages_clamps_to_max() { + let result = compute_max_in_flight_messages(usize::MAX); + assert!(result <= CHANNEL_MAX_IN_FLIGHT_MESSAGES); + } +} diff --git a/src/openhuman/composio/trigger_history.rs b/src/openhuman/composio/trigger_history.rs index f98f41fec..6f96181c5 100644 --- a/src/openhuman/composio/trigger_history.rs +++ b/src/openhuman/composio/trigger_history.rs @@ -278,4 +278,58 @@ mod tests { assert_eq!(history.entries[1].metadata_id, "id-1"); assert!(PathBuf::from(&history.current_day_file).exists()); } + + #[test] + fn list_recent_with_limit_one() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + + let store = ComposioTriggerHistoryStore::new(&workspace).expect("store"); + store + .record_trigger("gmail", "NEW_MSG", "id-1", "uuid-1", &serde_json::json!({})) + .expect("record"); + store + .record_trigger("slack", "NEW_MSG", "id-2", "uuid-2", &serde_json::json!({})) + .expect("record"); + + let history = store.list_recent(1).expect("list"); + assert_eq!(history.entries.len(), 1); + assert_eq!(history.entries[0].metadata_id, "id-2"); + } + + #[test] + fn list_recent_empty_store() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + + let store = ComposioTriggerHistoryStore::new(&workspace).expect("store"); + let history = store.list_recent(10).expect("list"); + assert!(history.entries.is_empty()); + } + + #[test] + fn record_trigger_returns_entry_with_correct_fields() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace dir"); + + let store = ComposioTriggerHistoryStore::new(&workspace).expect("store"); + let entry = store + .record_trigger( + "github", + "PR_OPENED", + "pr-42", + "uuid-42", + &serde_json::json!({"pr": 42}), + ) + .expect("record"); + + assert_eq!(entry.toolkit, "github"); + assert_eq!(entry.trigger, "PR_OPENED"); + assert_eq!(entry.metadata_id, "pr-42"); + assert_eq!(entry.metadata_uuid, "uuid-42"); + assert!(entry.received_at_ms > 0); + } } diff --git a/src/openhuman/doctor/schemas.rs b/src/openhuman/doctor/schemas.rs index 78801b0ba..c5814181f 100644 --- a/src/openhuman/doctor/schemas.rs +++ b/src/openhuman/doctor/schemas.rs @@ -99,3 +99,80 @@ fn read_optional( fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_two() { + assert_eq!(all_controller_schemas().len(), 2); + } + + #[test] + fn all_controllers_returns_two() { + assert_eq!(all_registered_controllers().len(), 2); + } + + #[test] + fn report_schema() { + let s = schemas("report"); + assert_eq!(s.namespace, "doctor"); + assert_eq!(s.function, "report"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn models_schema_has_optional_use_cache() { + let s = schemas("models"); + assert_eq!(s.function, "models"); + let use_cache = s.inputs.iter().find(|f| f.name == "use_cache"); + assert!(use_cache.is_some_and(|f| !f.required)); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn read_optional_returns_none_for_missing() { + let m = Map::new(); + let result: Option = read_optional(&m, "use_cache").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn read_optional_returns_none_for_null() { + let mut m = Map::new(); + m.insert("use_cache".into(), Value::Null); + let result: Option = read_optional(&m, "use_cache").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn read_optional_returns_some_for_value() { + let mut m = Map::new(); + m.insert("use_cache".into(), Value::Bool(true)); + let result: Option = read_optional(&m, "use_cache").unwrap(); + assert_eq!(result, Some(true)); + } + + #[test] + fn read_optional_errors_on_wrong_type() { + let mut m = Map::new(); + m.insert("use_cache".into(), Value::String("yes".into())); + let err = read_optional::(&m, "use_cache").unwrap_err(); + assert!(err.contains("invalid")); + } +} diff --git a/src/openhuman/encryption/schemas.rs b/src/openhuman/encryption/schemas.rs index d7ec31d87..f6ffeb7b8 100644 --- a/src/openhuman/encryption/schemas.rs +++ b/src/openhuman/encryption/schemas.rs @@ -101,3 +101,78 @@ fn read_required(params: &Map, key: &str) -> fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_two() { + assert_eq!(all_controller_schemas().len(), 2); + } + + #[test] + fn all_controllers_returns_two() { + assert_eq!(all_registered_controllers().len(), 2); + } + + #[test] + fn encrypt_schema_requires_plaintext() { + let s = schemas("encrypt_secret"); + assert_eq!(s.namespace, "encrypt"); + assert_eq!(s.function, "secret"); + assert_eq!(s.inputs.len(), 1); + assert!(s.inputs[0].required); + assert_eq!(s.inputs[0].name, "plaintext"); + } + + #[test] + fn decrypt_schema_requires_ciphertext() { + let s = schemas("decrypt_secret"); + assert_eq!(s.namespace, "decrypt"); + assert_eq!(s.function, "secret"); + assert_eq!(s.inputs.len(), 1); + assert!(s.inputs[0].required); + assert_eq!(s.inputs[0].name, "ciphertext"); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "encryption"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + assert_eq!(s.len(), c.len()); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn read_required_parses_string() { + let mut m = Map::new(); + m.insert("key".into(), Value::String("value".into())); + let result: String = read_required(&m, "key").unwrap(); + assert_eq!(result, "value"); + } + + #[test] + fn read_required_errors_on_missing_key() { + let m = Map::new(); + let err = read_required::(&m, "key").unwrap_err(); + assert!(err.contains("missing required param")); + } + + #[test] + fn read_required_errors_on_wrong_type() { + let mut m = Map::new(); + m.insert("key".into(), Value::Bool(true)); + let err = read_required::(&m, "key").unwrap_err(); + assert!(err.contains("invalid")); + } +} diff --git a/src/openhuman/learning/schemas.rs b/src/openhuman/learning/schemas.rs index 3f33dbe9e..c4fcf7e25 100644 --- a/src/openhuman/learning/schemas.rs +++ b/src/openhuman/learning/schemas.rs @@ -62,6 +62,43 @@ pub fn learning_schemas(function: &str) -> ControllerSchema { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_one() { + assert_eq!(all_learning_controller_schemas().len(), 1); + } + + #[test] + fn all_controllers_returns_one() { + assert_eq!(all_learning_registered_controllers().len(), 1); + } + + #[test] + fn linkedin_enrichment_schema() { + let s = learning_schemas("learning_linkedin_enrichment"); + assert_eq!(s.namespace, "learning"); + assert_eq!(s.function, "linkedin_enrichment"); + assert!(s.inputs.is_empty()); + assert!(!s.outputs.is_empty()); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = learning_schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_learning_controller_schemas(); + let c = all_learning_registered_controllers(); + assert_eq!(s[0].function, c[0].schema.function); + } +} + fn handle_linkedin_enrichment(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/memory/store/factories.rs b/src/openhuman/memory/store/factories.rs index 60f1109be..ca44b0f7e 100644 --- a/src/openhuman/memory/store/factories.rs +++ b/src/openhuman/memory/store/factories.rs @@ -93,3 +93,29 @@ pub fn create_memory_for_migration( ) -> anyhow::Result> { anyhow::bail!("memory migration is disabled for the unified namespace memory core") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_memory_backend_name_always_returns_namespace() { + assert_eq!(effective_memory_backend_name("sqlite", None), "namespace"); + assert_eq!(effective_memory_backend_name("anything", None), "namespace"); + assert_eq!(effective_memory_backend_name("", None), "namespace"); + } + + #[test] + fn create_memory_for_migration_always_errors() { + let tmp = tempfile::tempdir().unwrap(); + // Box doesn't impl Debug, so we can't use .unwrap_err(). + // Use match instead. + match create_memory_for_migration("any", tmp.path()) { + Ok(_) => panic!("expected error"), + Err(e) => assert!( + e.to_string().contains("migration is disabled"), + "unexpected error: {e}" + ), + } + } +} diff --git a/src/openhuman/memory/store/unified/helpers.rs b/src/openhuman/memory/store/unified/helpers.rs index 77a148212..3e219b932 100644 --- a/src/openhuman/memory/store/unified/helpers.rs +++ b/src/openhuman/memory/store/unified/helpers.rs @@ -188,3 +188,238 @@ impl UnifiedMemory { (1.0 / (1.0 + age_hours / 24.0)).clamp(0.0, 1.0) } } + +#[cfg(test)] +mod tests { + use super::UnifiedMemory; + use serde_json::json; + + // ── vec_to_bytes / bytes_to_vec ────────────────────────────────── + + #[test] + fn vec_bytes_roundtrip() { + let original = vec![1.0_f32, 2.5, -3.0, 0.0]; + let bytes = UnifiedMemory::vec_to_bytes(&original); + assert_eq!(bytes.len(), 16); // 4 floats * 4 bytes + let back = UnifiedMemory::bytes_to_vec(&bytes); + assert_eq!(back, original); + } + + #[test] + fn vec_to_bytes_empty() { + let bytes = UnifiedMemory::vec_to_bytes(&[]); + assert!(bytes.is_empty()); + let back = UnifiedMemory::bytes_to_vec(&bytes); + assert!(back.is_empty()); + } + + // ── cosine_similarity ──────────────────────────────────────────── + + #[test] + fn cosine_similarity_identical_vectors() { + let v = vec![1.0_f32, 0.0, 0.0]; + let sim = UnifiedMemory::cosine_similarity(&v, &v); + assert!((sim - 1.0).abs() < 1e-6); + } + + #[test] + fn cosine_similarity_orthogonal_vectors() { + let a = vec![1.0_f32, 0.0]; + let b = vec![0.0_f32, 1.0]; + let sim = UnifiedMemory::cosine_similarity(&a, &b); + assert!(sim.abs() < 1e-6); + } + + #[test] + fn cosine_similarity_different_lengths_returns_zero() { + let a = vec![1.0_f32, 0.0]; + let b = vec![1.0_f32, 0.0, 0.0]; + assert_eq!(UnifiedMemory::cosine_similarity(&a, &b), 0.0); + } + + #[test] + fn cosine_similarity_empty_vectors_returns_zero() { + assert_eq!(UnifiedMemory::cosine_similarity(&[], &[]), 0.0); + } + + #[test] + fn cosine_similarity_zero_vector_returns_zero() { + let a = vec![0.0_f32, 0.0]; + let b = vec![1.0_f32, 0.0]; + assert_eq!(UnifiedMemory::cosine_similarity(&a, &b), 0.0); + } + + // ── collapse_whitespace ────────────────────────────────────────── + + #[test] + fn collapse_whitespace_normalizes() { + assert_eq!( + UnifiedMemory::collapse_whitespace(" hello world "), + "hello world" + ); + } + + #[test] + fn collapse_whitespace_empty() { + assert_eq!(UnifiedMemory::collapse_whitespace(""), ""); + } + + // ── normalize_search_text ──────────────────────────────────────── + + #[test] + fn normalize_search_text_lowercases_and_strips_special() { + let result = UnifiedMemory::normalize_search_text("Hello, World! @#$ test"); + assert_eq!(result, "hello world test"); + } + + #[test] + fn normalize_search_text_preserves_separators() { + let result = UnifiedMemory::normalize_search_text("path/to_file-name.txt"); + assert_eq!(result, "path to file name txt"); + } + + // ── tokenize_search_terms ──────────────────────────────────────── + + #[test] + fn tokenize_search_terms_splits_correctly() { + let terms = UnifiedMemory::tokenize_search_terms("Hello World"); + assert_eq!(terms, vec!["hello", "world"]); + } + + #[test] + fn tokenize_search_terms_empty() { + assert!(UnifiedMemory::tokenize_search_terms("").is_empty()); + assert!(UnifiedMemory::tokenize_search_terms(" @#$ ").is_empty()); + } + + // ── normalize_graph_entity / predicate ─────────────────────────── + + #[test] + fn normalize_graph_entity_uppercases() { + assert_eq!( + UnifiedMemory::normalize_graph_entity(" rust language "), + "RUST LANGUAGE" + ); + } + + #[test] + fn normalize_graph_predicate_underscores_separators() { + assert_eq!( + UnifiedMemory::normalize_graph_predicate("is written in"), + "IS_WRITTEN_IN" + ); + } + + #[test] + fn normalize_graph_predicate_strips_trailing_underscores() { + assert_eq!(UnifiedMemory::normalize_graph_predicate(" has -- "), "HAS"); + } + + // ── json_string_array ──────────────────────────────────────────── + + #[test] + fn json_string_array_from_array_and_singular() { + let val = json!({"tags": ["a", "b"], "tag": "c"}); + let result = UnifiedMemory::json_string_array(&val, "tags", "tag"); + assert_eq!(result, vec!["a", "b", "c"]); + } + + #[test] + fn json_string_array_deduplicates() { + let val = json!({"tags": ["a", "a"], "tag": "a"}); + let result = UnifiedMemory::json_string_array(&val, "tags", "tag"); + assert_eq!(result, vec!["a"]); + } + + #[test] + fn json_string_array_empty_when_missing() { + let val = json!({}); + let result = UnifiedMemory::json_string_array(&val, "tags", "tag"); + assert!(result.is_empty()); + } + + #[test] + fn json_string_array_filters_empty_strings() { + let val = json!({"tags": ["", " ", "valid"]}); + let result = UnifiedMemory::json_string_array(&val, "tags", "tag"); + assert_eq!(result, vec!["valid"]); + } + + // ── merge_unique_string_arrays ─────────────────────────────────── + + #[test] + fn merge_unique_string_arrays_combines_and_deduplicates() { + let a = json!({"tags": ["x", "y"]}); + let b = json!({"tags": ["y", "z"]}); + let merged = UnifiedMemory::merge_unique_string_arrays(&a, &b, "tags", "tag"); + assert_eq!(merged, vec!["x", "y", "z"]); + } + + // ── json_i64 ───────────────────────────────────────────────────── + + #[test] + fn json_i64_from_integer() { + assert_eq!(UnifiedMemory::json_i64(&json!({"n": 42}), "n"), Some(42)); + } + + #[test] + fn json_i64_from_float() { + assert_eq!(UnifiedMemory::json_i64(&json!({"n": 3.9}), "n"), Some(3)); + } + + #[test] + fn json_i64_missing_key() { + assert_eq!(UnifiedMemory::json_i64(&json!({}), "n"), None); + } + + #[test] + fn json_i64_from_string_returns_none() { + assert_eq!(UnifiedMemory::json_i64(&json!({"n": "42"}), "n"), None); + } + + // ── recency_score ──────────────────────────────────────────────── + + #[test] + fn recency_score_current_time_is_one() { + let now = 1_700_000_000.0; + let score = UnifiedMemory::recency_score(now, now); + assert!((score - 1.0).abs() < 1e-6); + } + + #[test] + fn recency_score_old_document_is_lower() { + let now = 1_700_000_000.0; + let one_day_ago = now - 86400.0; + let score = UnifiedMemory::recency_score(one_day_ago, now); + assert!(score < 1.0); + assert!(score > 0.0); + } + + #[test] + fn recency_score_future_clamped_to_one() { + let now = 1_700_000_000.0; + let future = now + 86400.0; + let score = UnifiedMemory::recency_score(future, now); + assert!((score - 1.0).abs() < 1e-6); + } + + // ── chunk_document_content ─────────────────────────────────────── + + #[test] + fn chunk_document_content_returns_nonempty_for_content() { + let chunks = UnifiedMemory::chunk_document_content("Hello world. This is a test.", 100); + assert!(!chunks.is_empty()); + } + + #[test] + fn chunk_document_content_empty_input_returns_empty() { + let chunks = UnifiedMemory::chunk_document_content("", 100); + assert!(chunks.is_empty()); + } + + #[test] + fn chunk_document_content_whitespace_only_returns_empty() { + let chunks = UnifiedMemory::chunk_document_content(" \n \t ", 100); + assert!(chunks.is_empty()); + } +} diff --git a/src/openhuman/security/detect.rs b/src/openhuman/security/detect.rs index 568f65cc2..1ff32c0a5 100644 --- a/src/openhuman/security/detect.rs +++ b/src/openhuman/security/detect.rs @@ -143,4 +143,81 @@ mod tests { // Should return some sandbox (at least NoopSandbox) assert!(sandbox.is_available()); } + + #[test] + fn disabled_via_enabled_false_returns_noop() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: Some(false), + backend: SandboxBackend::Auto, + firejail_args: Vec::new(), + }, + ..SecurityConfig::default() + }; + let sandbox = create_sandbox(&config); + assert_eq!(sandbox.name(), "none"); + } + + #[test] + fn landlock_backend_on_non_linux_falls_back() { + // On macOS/Windows, Landlock isn't available — should fall back to Noop + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: None, + backend: SandboxBackend::Landlock, + firejail_args: Vec::new(), + }, + ..SecurityConfig::default() + }; + let sandbox = create_sandbox(&config); + if std::env::consts::OS != "linux" { + assert_eq!(sandbox.name(), "none"); + } + } + + #[test] + fn firejail_backend_on_non_linux_falls_back() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: None, + backend: SandboxBackend::Firejail, + firejail_args: Vec::new(), + }, + ..SecurityConfig::default() + }; + let sandbox = create_sandbox(&config); + if std::env::consts::OS != "linux" { + assert_eq!(sandbox.name(), "none"); + } + } + + #[test] + fn bubblewrap_backend_falls_back_when_unavailable() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: None, + backend: SandboxBackend::Bubblewrap, + firejail_args: Vec::new(), + }, + ..SecurityConfig::default() + }; + let sandbox = create_sandbox(&config); + // Bubblewrap probably isn't installed on CI/dev — expect fallback + assert!(sandbox.is_available()); + } + + #[test] + fn docker_backend_falls_back_when_unavailable() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: None, + backend: SandboxBackend::Docker, + firejail_args: Vec::new(), + }, + ..SecurityConfig::default() + }; + let sandbox = create_sandbox(&config); + // Docker may or may not be available + assert!(sandbox.is_available()); + } } diff --git a/src/openhuman/service/restart.rs b/src/openhuman/service/restart.rs index 0f0733831..65f786e32 100644 --- a/src/openhuman/service/restart.rs +++ b/src/openhuman/service/restart.rs @@ -205,4 +205,55 @@ mod tests { handle.cancel(); } + + #[tokio::test] + async fn service_restart_defaults_source_and_reason() { + event_bus::init_global(event_bus::DEFAULT_CAPACITY); + let outcome = service_restart(None, None) + .await + .expect("restart should succeed"); + assert!(outcome.value.accepted); + assert_eq!(outcome.value.source, "jsonrpc"); + assert_eq!(outcome.value.reason, "service.restart"); + } + + #[tokio::test] + async fn service_restart_trims_whitespace() { + event_bus::init_global(event_bus::DEFAULT_CAPACITY); + let outcome = service_restart(Some(" ui ".into()), Some(" user request ".into())) + .await + .expect("restart should succeed"); + assert_eq!(outcome.value.source, "ui"); + assert_eq!(outcome.value.reason, "user request"); + } + + #[tokio::test] + async fn service_restart_empty_strings_use_defaults() { + event_bus::init_global(event_bus::DEFAULT_CAPACITY); + let outcome = service_restart(Some("".into()), Some(" ".into())) + .await + .expect("restart should succeed"); + assert_eq!(outcome.value.source, "jsonrpc"); + assert_eq!(outcome.value.reason, "service.restart"); + } + + #[test] + fn restart_status_serializes() { + let status = RestartStatus { + accepted: true, + source: "test".into(), + reason: "testing".into(), + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"accepted\":true")); + assert!(json.contains("\"source\":\"test\"")); + } + + #[test] + fn apply_startup_restart_delay_from_env_noop_when_unset() { + // Ensure the env var is not set, then call — should not block + let _prev = std::env::var(RESTART_DELAY_ENV).ok(); + std::env::remove_var(RESTART_DELAY_ENV); + apply_startup_restart_delay_from_env(); // should return immediately + } } diff --git a/src/openhuman/service/schemas.rs b/src/openhuman/service/schemas.rs index 1254ea947..1f87ae362 100644 --- a/src/openhuman/service/schemas.rs +++ b/src/openhuman/service/schemas.rs @@ -254,3 +254,100 @@ fn handle_daemon_host_set(params: Map) -> ControllerFuture { fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_eight() { + assert_eq!(all_controller_schemas().len(), 8); + } + + #[test] + fn all_controllers_returns_eight() { + assert_eq!(all_registered_controllers().len(), 8); + } + + #[test] + fn all_schemas_use_service_namespace() { + for s in all_controller_schemas() { + assert_eq!(s.namespace, "service"); + assert!(!s.description.is_empty()); + } + } + + #[test] + fn lifecycle_schemas_have_no_inputs_except_restart() { + for fn_name in ["install", "start", "stop", "status", "uninstall"] { + let s = schemas(fn_name); + assert!( + s.inputs.is_empty(), + "{fn_name} should have no inputs, got {}", + s.inputs.len() + ); + } + } + + #[test] + fn restart_schema_has_optional_source_and_reason() { + let s = schemas("restart"); + assert_eq!(s.function, "restart"); + assert_eq!(s.inputs.len(), 2); + for input in &s.inputs { + assert!( + !input.required, + "restart input '{}' should be optional", + input.name + ); + } + } + + #[test] + fn daemon_host_get_schema_has_no_inputs() { + let s = schemas("daemon_host_get"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn daemon_host_set_requires_show_tray() { + let s = schemas("daemon_host_set"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "show_tray"); + assert!(s.inputs[0].required); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "service"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + assert_eq!(s.len(), c.len()); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn known_functions_resolve_correctly() { + for fn_name in [ + "install", + "restart", + "start", + "stop", + "status", + "uninstall", + "daemon_host_get", + "daemon_host_set", + ] { + let s = schemas(fn_name); + assert_ne!(s.function, "unknown", "{fn_name} fell through"); + } + } +} diff --git a/src/openhuman/skills/types.rs b/src/openhuman/skills/types.rs index 7efd38713..e38a7a3ee 100644 --- a/src/openhuman/skills/types.rs +++ b/src/openhuman/skills/types.rs @@ -68,3 +68,102 @@ pub enum ToolContent { Text { text: String }, Json { data: serde_json::Value }, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_result_success() { + let r = ToolResult::success("done"); + assert!(!r.is_error); + assert_eq!(r.text(), "done"); + assert_eq!(r.output(), "done"); + } + + #[test] + fn tool_result_error() { + let r = ToolResult::error("failed"); + assert!(r.is_error); + assert_eq!(r.text(), "failed"); + } + + #[test] + fn tool_result_json() { + let r = ToolResult::json(json!({"key": "value"})); + assert!(!r.is_error); + assert!(r.text().is_empty()); // text() skips JSON blocks + assert!(r.output().contains("key")); + } + + #[test] + fn tool_result_mixed_content() { + let r = ToolResult { + content: vec![ + ToolContent::Text { + text: "line1".into(), + }, + ToolContent::Json { + data: json!({"a": 1}), + }, + ToolContent::Text { + text: "line2".into(), + }, + ], + is_error: false, + }; + assert_eq!(r.text(), "line1\nline2"); + let output = r.output(); + assert!(output.contains("line1")); + assert!(output.contains("line2")); + assert!(output.contains("\"a\"")); + } + + #[test] + fn tool_result_serde_roundtrip() { + let r = ToolResult::success("hello"); + let json = serde_json::to_string(&r).unwrap(); + let back: ToolResult = serde_json::from_str(&json).unwrap(); + assert!(!back.is_error); + assert_eq!(back.text(), "hello"); + } + + #[test] + fn tool_content_text_serde() { + let c = ToolContent::Text { + text: "test".into(), + }; + let json = serde_json::to_string(&c).unwrap(); + assert!(json.contains("\"type\":\"text\"")); + let back: ToolContent = serde_json::from_str(&json).unwrap(); + match back { + ToolContent::Text { text } => assert_eq!(text, "test"), + _ => panic!("expected Text variant"), + } + } + + #[test] + fn tool_content_json_serde() { + let c = ToolContent::Json { + data: json!({"x": 1}), + }; + let json = serde_json::to_string(&c).unwrap(); + assert!(json.contains("\"type\":\"json\"")); + let back: ToolContent = serde_json::from_str(&json).unwrap(); + match back { + ToolContent::Json { data } => assert_eq!(data["x"], 1), + _ => panic!("expected Json variant"), + } + } + + #[test] + fn tool_result_empty_content() { + let r = ToolResult { + content: vec![], + is_error: false, + }; + assert!(r.text().is_empty()); + assert!(r.output().is_empty()); + } +} diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index 587e89879..6181f8c22 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -479,3 +479,163 @@ fn field_opt(name: &'static str, ty: TypeSchema, comment: &'static str) -> Field fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_ten() { + assert_eq!(all_controller_schemas().len(), 10); + } + + #[test] + fn all_controllers_returns_ten() { + assert_eq!(all_registered_controllers().len(), 10); + } + + #[test] + fn all_use_subconscious_namespace() { + for s in all_controller_schemas() { + assert_eq!(s.namespace, "subconscious"); + assert!(!s.description.is_empty()); + } + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn known_functions_resolve() { + for fn_name in [ + "status", + "trigger", + "tasks_list", + "tasks_add", + "tasks_update", + "tasks_remove", + "log_list", + "escalations_list", + "escalations_approve", + "escalations_dismiss", + ] { + let s = schemas(fn_name); + assert_ne!(s.function, "unknown", "{fn_name} fell through"); + } + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + } + + #[test] + fn status_schema_has_no_inputs() { + assert!(schemas("status").inputs.is_empty()); + } + + #[test] + fn trigger_schema_has_no_inputs() { + assert!(schemas("trigger").inputs.is_empty()); + } + + #[test] + fn tasks_add_requires_title() { + let s = schemas("tasks_add"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"title")); + } + + #[test] + fn tasks_update_requires_task_id() { + let s = schemas("tasks_update"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"task_id")); + } + + #[test] + fn tasks_remove_requires_task_id() { + let s = schemas("tasks_remove"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"task_id")); + } + + #[test] + fn escalations_approve_requires_escalation_id() { + let s = schemas("escalations_approve"); + assert!(s + .inputs + .iter() + .any(|f| f.name == "escalation_id" && f.required)); + } + + #[test] + fn escalations_dismiss_requires_escalation_id() { + let s = schemas("escalations_dismiss"); + assert!(s + .inputs + .iter() + .any(|f| f.name == "escalation_id" && f.required)); + } + + #[test] + fn log_list_has_optional_inputs() { + let s = schemas("log_list"); + for input in &s.inputs { + assert!( + !input.required, + "log_list input '{}' should be optional", + input.name + ); + } + } + + #[test] + fn tasks_list_has_optional_enabled_only() { + let s = schemas("tasks_list"); + let enabled = s.inputs.iter().find(|f| f.name == "enabled_only"); + assert!(enabled.is_some_and(|f| !f.required)); + } + + // ── Field helpers ────────────────────────────────────────────── + + #[test] + fn field_helper_is_required() { + let f = field("name", TypeSchema::String, "desc"); + assert!(f.required); + } + + #[test] + fn field_req_helper_is_required() { + let f = field_req("name", TypeSchema::String, "desc"); + assert!(f.required); + } + + #[test] + fn field_opt_helper_is_not_required() { + let f = field_opt("name", TypeSchema::String, "desc"); + assert!(!f.required); + } +} diff --git a/src/openhuman/tools/impl/system/proxy_config.rs b/src/openhuman/tools/impl/system/proxy_config.rs index 44c07852b..31a6dc973 100644 --- a/src/openhuman/tools/impl/system/proxy_config.rs +++ b/src/openhuman/tools/impl/system/proxy_config.rs @@ -508,4 +508,229 @@ mod tests { assert!(parsed["proxy"]["http_proxy"].is_null()); assert!(parsed["runtime_proxy"]["http_proxy"].is_null()); } + + // ── parse_scope ────────────────────────────────────────────────── + + #[test] + fn parse_scope_known_values() { + assert_eq!( + ProxyConfigTool::parse_scope("environment"), + Some(ProxyScope::Environment) + ); + assert_eq!( + ProxyConfigTool::parse_scope("env"), + Some(ProxyScope::Environment) + ); + assert_eq!( + ProxyConfigTool::parse_scope("openhuman"), + Some(ProxyScope::OpenHuman) + ); + assert_eq!( + ProxyConfigTool::parse_scope("internal"), + Some(ProxyScope::OpenHuman) + ); + assert_eq!( + ProxyConfigTool::parse_scope("core"), + Some(ProxyScope::OpenHuman) + ); + assert_eq!( + ProxyConfigTool::parse_scope("services"), + Some(ProxyScope::Services) + ); + assert_eq!( + ProxyConfigTool::parse_scope("service"), + Some(ProxyScope::Services) + ); + } + + #[test] + fn parse_scope_case_insensitive() { + assert_eq!( + ProxyConfigTool::parse_scope("SERVICES"), + Some(ProxyScope::Services) + ); + assert_eq!( + ProxyConfigTool::parse_scope(" ENV "), + Some(ProxyScope::Environment) + ); + } + + #[test] + fn parse_scope_unknown_returns_none() { + assert!(ProxyConfigTool::parse_scope("unknown").is_none()); + assert!(ProxyConfigTool::parse_scope("").is_none()); + } + + // ── parse_string_list ──────────────────────────────────────────── + + #[test] + fn parse_string_list_from_csv() { + let result = + ProxyConfigTool::parse_string_list(&json!("provider.openai,tool.browser"), "services") + .unwrap(); + assert_eq!(result, vec!["provider.openai", "tool.browser"]); + } + + #[test] + fn parse_string_list_from_array() { + let result = ProxyConfigTool::parse_string_list( + &json!(["provider.openai", "tool.browser"]), + "services", + ) + .unwrap(); + assert_eq!(result, vec!["provider.openai", "tool.browser"]); + } + + #[test] + fn parse_string_list_trims_and_filters_empty() { + let result = ProxyConfigTool::parse_string_list(&json!(" a , , b "), "services").unwrap(); + assert_eq!(result, vec!["a", "b"]); + } + + #[test] + fn parse_string_list_rejects_non_string_array_elements() { + let result = ProxyConfigTool::parse_string_list(&json!([1, 2, 3]), "services"); + assert!(result.is_err()); + } + + #[test] + fn parse_string_list_rejects_object() { + let result = ProxyConfigTool::parse_string_list(&json!({}), "services"); + assert!(result.is_err()); + } + + // ── parse_optional_string_update ───────────────────────────────── + + #[test] + fn parse_optional_string_update_unset() { + let result = + ProxyConfigTool::parse_optional_string_update(&json!({}), "http_proxy").unwrap(); + assert!(matches!(result, MaybeSet::Unset)); + } + + #[test] + fn parse_optional_string_update_null() { + let result = ProxyConfigTool::parse_optional_string_update( + &json!({"http_proxy": null}), + "http_proxy", + ) + .unwrap(); + assert!(matches!(result, MaybeSet::Null)); + } + + #[test] + fn parse_optional_string_update_empty_string_is_null() { + let result = + ProxyConfigTool::parse_optional_string_update(&json!({"http_proxy": ""}), "http_proxy") + .unwrap(); + assert!(matches!(result, MaybeSet::Null)); + } + + #[test] + fn parse_optional_string_update_set() { + let result = ProxyConfigTool::parse_optional_string_update( + &json!({"http_proxy": "http://proxy:8080"}), + "http_proxy", + ) + .unwrap(); + assert!(matches!(result, MaybeSet::Set(ref v) if v == "http://proxy:8080")); + } + + #[test] + fn parse_optional_string_update_rejects_non_string() { + let result = + ProxyConfigTool::parse_optional_string_update(&json!({"http_proxy": 42}), "http_proxy"); + assert!(result.is_err()); + } + + // ── env_snapshot ───────────────────────────────────────────────── + + #[test] + fn env_snapshot_returns_object() { + let snap = ProxyConfigTool::env_snapshot(); + assert!(snap.is_object()); + assert!(snap.get("HTTP_PROXY").is_some()); + assert!(snap.get("HTTPS_PROXY").is_some()); + } + + // ── proxy_json ─────────────────────────────────────────────────── + + #[test] + fn proxy_json_returns_object_with_expected_fields() { + let config = ProxyConfig::default(); + let json = ProxyConfigTool::proxy_json(&config); + assert!(json.get("enabled").is_some()); + assert!(json.get("scope").is_some()); + assert!(json.get("http_proxy").is_some()); + } + + // ── tool metadata ──────────────────────────────────────────────── + + #[test] + fn tool_name_and_description() { + let tmp = TempDir::new().unwrap(); + let tool = ProxyConfigTool::new( + Arc::new(Config { + workspace_dir: tmp.path().to_path_buf(), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }), + test_security(), + ); + assert_eq!(tool.name(), "proxy_config"); + assert!(!tool.description().is_empty()); + } + + #[tokio::test] + async fn parameters_schema_is_valid() { + let tmp = TempDir::new().unwrap(); + let tool = ProxyConfigTool::new(test_config(&tmp).await, test_security()); + let schema = tool.parameters_schema(); + assert!(schema.is_object()); + assert!(schema.get("properties").is_some() || schema.get("type").is_some()); + } + + // ── require_write_access ───────────────────────────────────────── + + #[tokio::test] + async fn blocks_set_in_readonly_mode() { + let tmp = TempDir::new().unwrap(); + let readonly = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::ReadOnly, + ..SecurityPolicy::default() + }); + let tool = ProxyConfigTool::new(test_config(&tmp).await, readonly); + let result = tool + .execute(json!({"action": "set", "enabled": true})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("read-only")); + } + + #[tokio::test] + async fn missing_action_returns_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProxyConfigTool::new(test_config(&tmp).await, test_security()); + let result = tool.execute(json!({})).await; + // Missing action may return Err or ToolResult::error + match result { + Err(_) => {} + Ok(r) => { + // Some implementations return success with help text; just verify it ran + let _ = r; + } + } + } + + #[tokio::test] + async fn unknown_action_returns_error() { + let tmp = TempDir::new().unwrap(); + let tool = ProxyConfigTool::new(test_config(&tmp).await, test_security()); + let result = tool.execute(json!({"action": "delete"})).await; + match result { + Err(e) => assert!(e.to_string().contains("Unknown action")), + Ok(r) => assert!(r.is_error, "expected error for unknown action"), + } + } } diff --git a/src/openhuman/tree_summarizer/schemas.rs b/src/openhuman/tree_summarizer/schemas.rs index 3de27a5ca..6c285af00 100644 --- a/src/openhuman/tree_summarizer/schemas.rs +++ b/src/openhuman/tree_summarizer/schemas.rs @@ -289,3 +289,171 @@ fn type_name(value: &Value) -> &'static str { Value::Object(_) => "object", } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_schemas_returns_five() { + assert_eq!(all_controller_schemas().len(), 5); + } + + #[test] + fn all_controllers_returns_five() { + assert_eq!(all_registered_controllers().len(), 5); + } + + #[test] + fn all_use_tree_summarizer_namespace() { + for s in all_controller_schemas() { + assert_eq!(s.namespace, "tree_summarizer"); + assert!(!s.description.is_empty()); + } + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn known_functions_resolve() { + for fn_name in ["ingest", "run", "query", "status", "rebuild"] { + let s = schemas(fn_name); + assert_ne!(s.function, "unknown", "{fn_name} fell through"); + } + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + } + + #[test] + fn ingest_requires_namespace_and_content() { + let s = schemas("ingest"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"namespace")); + assert!(required.contains(&"content")); + } + + #[test] + fn query_requires_namespace() { + let s = schemas("query"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"namespace")); + } + + #[test] + fn status_requires_namespace() { + let s = schemas("status"); + assert!(s.inputs.iter().any(|f| f.name == "namespace" && f.required)); + } + + // ── Param helper tests ────────────────────────────────────────── + + #[test] + fn read_required_parses_string() { + let mut m = Map::new(); + m.insert("key".into(), Value::String("val".into())); + let result: String = read_required(&m, "key").unwrap(); + assert_eq!(result, "val"); + } + + #[test] + fn read_required_errors_on_missing() { + let m = Map::new(); + let err = read_required::(&m, "key").unwrap_err(); + assert!(err.contains("missing required")); + } + + #[test] + fn read_optional_returns_none_for_missing() { + let m = Map::new(); + let result: Option = read_optional(&m, "key").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn read_optional_returns_none_for_null() { + let mut m = Map::new(); + m.insert("key".into(), Value::Null); + let result: Option = read_optional(&m, "key").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn read_optional_returns_some_for_value() { + let mut m = Map::new(); + m.insert("key".into(), Value::String("val".into())); + let result: Option = read_optional(&m, "key").unwrap(); + assert_eq!(result, Some("val".into())); + } + + #[test] + fn read_optional_timestamp_valid_rfc3339() { + let mut m = Map::new(); + m.insert("ts".into(), Value::String("2026-04-17T12:00:00Z".into())); + let result = read_optional_timestamp(&m, "ts").unwrap(); + assert!(result.is_some()); + } + + #[test] + fn read_optional_timestamp_invalid_format() { + let mut m = Map::new(); + m.insert("ts".into(), Value::String("not-a-date".into())); + assert!(read_optional_timestamp(&m, "ts").is_err()); + } + + #[test] + fn read_optional_timestamp_non_string() { + let mut m = Map::new(); + m.insert("ts".into(), json!(12345)); + assert!(read_optional_timestamp(&m, "ts").is_err()); + } + + #[test] + fn read_optional_timestamp_none_for_missing() { + let m = Map::new(); + assert!(read_optional_timestamp(&m, "ts").unwrap().is_none()); + } + + // ── type_name ─────────────────────────────────────────────────── + + #[test] + fn type_name_covers_all_variants() { + assert_eq!(type_name(&Value::Null), "null"); + assert_eq!(type_name(&Value::Bool(true)), "bool"); + assert_eq!(type_name(&json!(42)), "number"); + assert_eq!(type_name(&json!("s")), "string"); + assert_eq!(type_name(&json!([1])), "array"); + assert_eq!(type_name(&json!({})), "object"); + } + + // ── namespace_input helper ─────────────────────────────────────── + + #[test] + fn namespace_input_is_required_string() { + let f = namespace_input("test"); + assert_eq!(f.name, "namespace"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::String)); + } +} diff --git a/src/openhuman/update/schemas.rs b/src/openhuman/update/schemas.rs index 3783ae590..5f24a8b9c 100644 --- a/src/openhuman/update/schemas.rs +++ b/src/openhuman/update/schemas.rs @@ -114,3 +114,64 @@ fn handle_apply(params: Map) -> ControllerFuture { fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_two() { + assert_eq!(all_controller_schemas().len(), 2); + } + + #[test] + fn all_controllers_returns_two() { + assert_eq!(all_registered_controllers().len(), 2); + } + + #[test] + fn check_schema() { + let s = schemas("check"); + assert_eq!(s.namespace, "update"); + assert_eq!(s.function, "check"); + assert!(s.inputs.is_empty()); + assert!(!s.outputs.is_empty()); + } + + #[test] + fn apply_schema_requires_download_url_and_asset_name() { + let s = schemas("apply"); + assert_eq!(s.function, "apply"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"download_url")); + assert!(required.contains(&"asset_name")); + } + + #[test] + fn apply_schema_has_optional_staging_dir() { + let s = schemas("apply"); + let staging = s.inputs.iter().find(|f| f.name == "staging_dir"); + assert!(staging.is_some_and(|f| !f.required)); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "update"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + } + } +}