From e4d7cc7eb134f2c9aafb7350d8a349558fd07c90 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 16 Apr 2026 18:40:23 +0530 Subject: [PATCH 1/3] =?UTF-8?q?test(coverage):=20batch=205=E2=80=938=20?= =?UTF-8?q?=E2=80=94=20Rust=20unit=20tests=20toward=2080%=20for=2020=20mod?= =?UTF-8?q?ules=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 244 new unit tests across 20 modules to push line coverage from 75.06% → 75.70% overall. Modules improved: - api/socket: 77% → 100% - rpc/dispatch: 75% → 100% - config/schema/channels: 77% → 100% - composio/gmail/sync: 78% → 97% - composio/notion/sync: 77% → 96% - webhooks/router: 78% → 96% - agent/hooks: 78% → 92% - config/schema/proxy: 76% → 90% - local_ai/device: 79% → 89% - text_input/schemas: 73% → 89% - agent/harness/interrupt: 76% → 88% - billing/schemas: 79% → 87% - screen_intelligence/schemas: 78% → 81% - about_app/types, health/schemas, app_state/schemas, core/types, config/schema/identity_cost, cron/scheduler Tests cover: schema catalog integrity, param deserialization, serde roundtrips, helper functions, edge cases, error paths. All 3974 tests pass. --- Cargo.lock | 2 +- app/src-tauri/Cargo.lock | 2 +- src/api/socket.rs | 50 +++ src/core/types.rs | 123 ++++++++ src/openhuman/about_app/types.rs | 88 ++++++ src/openhuman/agent/harness/interrupt.rs | 69 +++++ src/openhuman/agent/hooks.rs | 115 +++++++ src/openhuman/app_state/schemas.rs | 88 ++++++ src/openhuman/billing/schemas.rs | 288 ++++++++++++++++++ .../composio/providers/gmail/sync.rs | 92 ++++++ .../composio/providers/notion/sync.rs | 110 +++++++ src/openhuman/config/schema/channels.rs | 164 ++++++++++ src/openhuman/config/schema/identity_cost.rs | 106 +++++++ src/openhuman/config/schema/proxy.rs | 243 +++++++++++++++ src/openhuman/cron/scheduler.rs | 139 +++++++++ src/openhuman/health/schemas.rs | 51 ++++ src/openhuman/local_ai/device.rs | 49 +++ src/openhuman/screen_intelligence/schemas.rs | 114 +++++++ src/openhuman/text_input/schemas.rs | 155 ++++++++++ src/openhuman/webhooks/router.rs | 206 +++++++++++++ src/rpc/dispatch.rs | 20 ++ 21 files changed, 2272 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96bd1e7513..304e142b58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,7 +4307,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.52.15" +version = "0.52.16" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 4411635306..aa1c9d649b 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.14" +version = "0.52.16" dependencies = [ "env_logger", "log", diff --git a/src/api/socket.rs b/src/api/socket.rs index 32eacfc4a5..6462af71f1 100644 --- a/src/api/socket.rs +++ b/src/api/socket.rs @@ -12,3 +12,53 @@ pub fn websocket_url(http_or_https_base: &str) -> String { }; format!("{}/socket.io/?EIO=4&transport=websocket", ws_base) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_https_to_wss() { + let url = websocket_url("https://api.tinyhumans.ai"); + assert_eq!( + url, + "wss://api.tinyhumans.ai/socket.io/?EIO=4&transport=websocket" + ); + } + + #[test] + fn converts_http_to_ws() { + let url = websocket_url("http://localhost:3000"); + assert_eq!( + url, + "ws://localhost:3000/socket.io/?EIO=4&transport=websocket" + ); + } + + #[test] + fn passes_through_unknown_scheme() { + let url = websocket_url("ftp://example.com"); + assert_eq!( + url, + "ftp://example.com/socket.io/?EIO=4&transport=websocket" + ); + } + + #[test] + fn strips_trailing_slash() { + let url = websocket_url("https://api.tinyhumans.ai/"); + assert_eq!( + url, + "wss://api.tinyhumans.ai/socket.io/?EIO=4&transport=websocket" + ); + } + + #[test] + fn strips_multiple_trailing_slashes() { + let url = websocket_url("https://api.tinyhumans.ai///"); + assert_eq!( + url, + "wss://api.tinyhumans.ai/socket.io/?EIO=4&transport=websocket" + ); + } +} diff --git a/src/core/types.rs b/src/core/types.rs index 39132faabb..35badb343e 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -140,3 +140,126 @@ pub struct AppState { /// The current version of the OpenHuman core binary, usually from `CARGO_PKG_VERSION`. pub core_version: String, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn invocation_result_ok_serializes_value() { + let result = InvocationResult::ok(json!({"key": "value"})).unwrap(); + assert_eq!(result.value, json!({"key": "value"})); + assert!(result.logs.is_empty()); + } + + #[test] + fn invocation_result_with_logs() { + let result = + InvocationResult::with_logs(json!(42), vec!["log1".into(), "log2".into()]).unwrap(); + assert_eq!(result.value, json!(42)); + assert_eq!(result.logs.len(), 2); + } + + #[test] + fn invocation_to_rpc_json_no_logs_returns_value_directly() { + let inv = InvocationResult { + value: json!({"data": true}), + logs: vec![], + }; + let json = invocation_to_rpc_json(inv); + assert_eq!(json, json!({"data": true})); + } + + #[test] + fn invocation_to_rpc_json_with_logs_wraps_in_envelope() { + let inv = InvocationResult { + value: json!({"data": true}), + logs: vec!["info".into()], + }; + let json = invocation_to_rpc_json(inv); + assert!(json.get("result").is_some()); + assert!(json.get("logs").is_some()); + assert_eq!(json["result"], json!({"data": true})); + assert_eq!(json["logs"][0], "info"); + } + + #[test] + fn command_response_serde_roundtrip() { + let resp = CommandResponse { + result: "ok".to_string(), + logs: vec!["log1".into()], + }; + let json = serde_json::to_string(&resp).unwrap(); + let back: CommandResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(back.result, "ok"); + assert_eq!(back.logs.len(), 1); + } + + #[test] + fn rpc_request_deserializes() { + let json = r#"{"jsonrpc":"2.0","id":1,"method":"test","params":{}}"#; + let req: RpcRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.method, "test"); + assert_eq!(req.id, json!(1)); + } + + #[test] + fn rpc_request_params_default_to_null() { + let json = r#"{"jsonrpc":"2.0","id":"abc","method":"foo"}"#; + let req: RpcRequest = serde_json::from_str(json).unwrap(); + assert!(req.params.is_null()); + } + + #[test] + fn rpc_success_serializes() { + let resp = RpcSuccess { + jsonrpc: "2.0", + id: json!(42), + result: json!({"ok": true}), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"jsonrpc\":\"2.0\"")); + assert!(json.contains("\"id\":42")); + } + + #[test] + fn rpc_failure_serializes() { + let resp = RpcFailure { + jsonrpc: "2.0", + id: json!("req-1"), + error: RpcError { + code: -32601, + message: "Method not found".into(), + data: Some(json!({"detail": "unknown"})), + }, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("-32601")); + assert!(json.contains("Method not found")); + } + + #[test] + fn rpc_failure_serializes_without_data() { + let resp = RpcFailure { + jsonrpc: "2.0", + id: json!(null), + error: RpcError { + code: -32700, + message: "Parse error".into(), + data: None, + }, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("-32700")); + } + + #[test] + fn app_state_clone() { + let state = AppState { + core_version: "0.1.0".into(), + }; + let cloned = state.clone(); + assert_eq!(cloned.core_version, "0.1.0"); + } +} diff --git a/src/openhuman/about_app/types.rs b/src/openhuman/about_app/types.rs index 897676bbce..8b9b87776f 100644 --- a/src/openhuman/about_app/types.rs +++ b/src/openhuman/about_app/types.rs @@ -144,4 +144,92 @@ mod tests { "\"coming_soon\"" ); } + + #[test] + fn category_all_has_10_variants() { + assert_eq!(CapabilityCategory::ALL.len(), 10); + } + + #[test] + fn category_as_str_roundtrips_through_from_str() { + for cat in CapabilityCategory::ALL { + let s = cat.as_str(); + let parsed: CapabilityCategory = s.parse().unwrap(); + assert_eq!(parsed, cat); + } + } + + #[test] + fn category_from_str_accepts_aliases() { + assert_eq!( + "local-ai".parse::().unwrap(), + CapabilityCategory::LocalAI + ); + assert_eq!( + "local ai".parse::().unwrap(), + CapabilityCategory::LocalAI + ); + assert_eq!( + "localai".parse::().unwrap(), + CapabilityCategory::LocalAI + ); + assert_eq!( + "screen-intelligence".parse::().unwrap(), + CapabilityCategory::ScreenIntelligence + ); + assert_eq!( + "screen intelligence".parse::().unwrap(), + CapabilityCategory::ScreenIntelligence + ); + } + + #[test] + fn category_from_str_is_case_insensitive() { + assert_eq!( + "CONVERSATION".parse::().unwrap(), + CapabilityCategory::Conversation + ); + assert_eq!( + " Team ".parse::().unwrap(), + CapabilityCategory::Team + ); + } + + #[test] + fn category_from_str_rejects_unknown() { + let err = "bogus".parse::().unwrap_err(); + assert!(err.contains("unknown capability category")); + assert!(err.contains("bogus")); + } + + #[test] + fn status_as_str_covers_all_variants() { + assert_eq!(CapabilityStatus::Stable.as_str(), "stable"); + assert_eq!(CapabilityStatus::Beta.as_str(), "beta"); + assert_eq!(CapabilityStatus::ComingSoon.as_str(), "coming_soon"); + assert_eq!(CapabilityStatus::Deprecated.as_str(), "deprecated"); + } + + #[test] + fn status_serde_roundtrip() { + for status in [ + CapabilityStatus::Stable, + CapabilityStatus::Beta, + CapabilityStatus::ComingSoon, + CapabilityStatus::Deprecated, + ] { + let json = serde_json::to_string(&status).unwrap(); + let back: CapabilityStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(back, status); + } + } + + #[test] + fn category_serde_roundtrip_all() { + for cat in CapabilityCategory::ALL { + let json = serde_json::to_string(&cat).unwrap(); + let back: CapabilityCategory = serde_json::from_str(&json).unwrap(); + assert_eq!(back, cat); + } + } } diff --git a/src/openhuman/agent/harness/interrupt.rs b/src/openhuman/agent/harness/interrupt.rs index e0cabc7816..ca8befbc30 100644 --- a/src/openhuman/agent/harness/interrupt.rs +++ b/src/openhuman/agent/harness/interrupt.rs @@ -95,3 +95,72 @@ pub fn check_interrupt(fence: &InterruptFence) -> Result<(), InterruptedError> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_fence_is_not_interrupted() { + let fence = InterruptFence::new(); + assert!(!fence.is_interrupted()); + } + + #[test] + fn trigger_sets_interrupted() { + let fence = InterruptFence::new(); + fence.trigger(); + assert!(fence.is_interrupted()); + } + + #[test] + fn reset_clears_interrupted() { + let fence = InterruptFence::new(); + fence.trigger(); + assert!(fence.is_interrupted()); + fence.reset(); + assert!(!fence.is_interrupted()); + } + + #[test] + fn flag_handle_shares_state() { + let fence = InterruptFence::new(); + let handle = fence.flag_handle(); + handle.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(fence.is_interrupted()); + } + + #[test] + fn clone_shares_state() { + let fence = InterruptFence::new(); + let clone = fence.clone(); + fence.trigger(); + assert!(clone.is_interrupted()); + } + + #[test] + fn default_is_not_interrupted() { + let fence = InterruptFence::default(); + assert!(!fence.is_interrupted()); + } + + #[test] + fn check_interrupt_ok_when_not_triggered() { + let fence = InterruptFence::new(); + assert!(check_interrupt(&fence).is_ok()); + } + + #[test] + fn check_interrupt_err_when_triggered() { + let fence = InterruptFence::new(); + fence.trigger(); + let err = check_interrupt(&fence).unwrap_err(); + assert_eq!(err.to_string(), "operation interrupted by user"); + } + + #[test] + fn interrupted_error_display() { + let err = InterruptedError; + assert_eq!(format!("{err}"), "operation interrupted by user"); + } +} diff --git a/src/openhuman/agent/hooks.rs b/src/openhuman/agent/hooks.rs index a425ec0157..3f100bd5d5 100644 --- a/src/openhuman/agent/hooks.rs +++ b/src/openhuman/agent/hooks.rs @@ -91,6 +91,121 @@ pub trait PostTurnHook: Send + Sync { async fn on_turn_complete(&self, ctx: &TurnContext) -> anyhow::Result<()>; } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_success_includes_char_count() { + let out = sanitize_tool_output("hello world", "read_file", true); + assert_eq!(out, "read_file: ok (11 chars)"); + } + + #[test] + fn sanitize_success_empty_output() { + let out = sanitize_tool_output("", "write_file", true); + assert_eq!(out, "write_file: ok (0 chars)"); + } + + #[test] + fn sanitize_failure_timeout() { + let out = sanitize_tool_output("connection timeout after 30s", "http_request", false); + assert_eq!(out, "http_request: failed (timeout)"); + } + + #[test] + fn sanitize_failure_not_found() { + let out = sanitize_tool_output("no such file or directory", "read_file", false); + assert_eq!(out, "read_file: failed (not_found)"); + } + + #[test] + fn sanitize_failure_not_found_variant() { + let out = sanitize_tool_output("resource Not Found", "api_call", false); + assert_eq!(out, "api_call: failed (not_found)"); + } + + #[test] + fn sanitize_failure_permission_denied() { + let out = sanitize_tool_output("Permission denied", "exec", false); + assert_eq!(out, "exec: failed (permission_denied)"); + } + + #[test] + fn sanitize_failure_connection_error() { + let out = sanitize_tool_output("network unreachable", "fetch", false); + assert_eq!(out, "fetch: failed (connection_error)"); + } + + #[test] + fn sanitize_failure_connection_variant() { + let out = sanitize_tool_output("Connection refused", "fetch", false); + assert_eq!(out, "fetch: failed (connection_error)"); + } + + #[test] + fn sanitize_failure_parse_error() { + let out = sanitize_tool_output("invalid JSON syntax", "parse", false); + assert_eq!(out, "parse: failed (parse_error)"); + } + + #[test] + fn sanitize_failure_parse_variant() { + let out = sanitize_tool_output("failed to parse response", "api", false); + assert_eq!(out, "api: failed (parse_error)"); + } + + #[test] + fn sanitize_failure_unknown_tool() { + let out = sanitize_tool_output("unknown tool requested", "bad_tool", false); + assert_eq!(out, "bad_tool: failed (unknown_tool)"); + } + + #[test] + fn sanitize_failure_generic_error() { + let out = sanitize_tool_output("something went wrong", "tool", false); + assert_eq!(out, "tool: failed (error)"); + } + + #[test] + fn turn_context_serde_roundtrip() { + let ctx = TurnContext { + user_message: "hello".into(), + assistant_response: "hi".into(), + tool_calls: vec![ToolCallRecord { + name: "read".into(), + arguments: serde_json::json!({"path": "/tmp"}), + success: true, + output_summary: "read: ok (100 chars)".into(), + duration_ms: 42, + }], + turn_duration_ms: 500, + session_id: Some("sess-1".into()), + iteration_count: 2, + }; + let json = serde_json::to_string(&ctx).unwrap(); + let back: TurnContext = serde_json::from_str(&json).unwrap(); + assert_eq!(back.user_message, "hello"); + assert_eq!(back.tool_calls.len(), 1); + assert_eq!(back.tool_calls[0].name, "read"); + assert_eq!(back.iteration_count, 2); + } + + #[tokio::test] + async fn fire_hooks_accepts_empty_hook_list() { + let ctx = TurnContext { + user_message: "x".into(), + assistant_response: "y".into(), + tool_calls: vec![], + turn_duration_ms: 1, + session_id: None, + iteration_count: 1, + }; + // Should not panic + fire_hooks(&[], ctx); + } +} + /// Fire all hooks in parallel, logging errors without blocking the caller. pub fn fire_hooks(hooks: &[Arc], ctx: TurnContext) { log::debug!( diff --git a/src/openhuman/app_state/schemas.rs b/src/openhuman/app_state/schemas.rs index 94f8d3918d..4e6a5fcc8a 100644 --- a/src/openhuman/app_state/schemas.rs +++ b/src/openhuman/app_state/schemas.rs @@ -121,3 +121,91 @@ fn optional_json(name: &'static str, comment: &'static str) -> FieldSchema { required: false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_schemas_returns_two() { + assert_eq!(all_app_state_controller_schemas().len(), 2); + } + + #[test] + fn all_controllers_returns_two() { + assert_eq!(all_app_state_registered_controllers().len(), 2); + } + + #[test] + fn snapshot_schema() { + let s = app_state_schemas("snapshot"); + assert_eq!(s.namespace, "app_state"); + assert_eq!(s.function, "snapshot"); + assert!(s.inputs.is_empty()); + assert!(!s.outputs.is_empty()); + } + + #[test] + fn update_local_state_schema() { + let s = app_state_schemas("update_local_state"); + assert_eq!(s.namespace, "app_state"); + assert_eq!(s.function, "update_local_state"); + assert_eq!(s.inputs.len(), 3); + for input in &s.inputs { + assert!(!input.required, "input '{}' should be optional", input.name); + } + } + + #[test] + fn unknown_function_returns_unknown() { + let s = app_state_schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "app_state"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_app_state_controller_schemas(); + let c = all_app_state_registered_controllers(); + assert_eq!(s.len(), c.len()); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + assert_eq!(schema.namespace, ctrl.schema.namespace); + } + } + + #[test] + fn all_schemas_use_app_state_namespace() { + for s in all_app_state_controller_schemas() { + assert_eq!(s.namespace, "app_state"); + assert!(!s.description.is_empty()); + } + } + + #[test] + fn optional_json_helper() { + let f = optional_json("key", "desc"); + assert_eq!(f.name, "key"); + assert!(!f.required); + assert!(matches!(f.ty, TypeSchema::Json)); + } + + #[test] + fn deserialize_update_local_state_params_empty() { + let params: UpdateLocalStateParams = + serde_json::from_value(serde_json::Value::Object(Map::new())).unwrap(); + assert!(params.encryption_key.is_none()); + assert!(params.primary_wallet_address.is_none()); + assert!(params.onboarding_tasks.is_none()); + } + + #[test] + fn deserialize_update_local_state_params_with_values() { + let mut m = Map::new(); + // encryption_key is Option> — sending a string value sets Some(Some("...")) + m.insert("encryptionKey".into(), serde_json::json!("my-key")); + let params: UpdateLocalStateParams = + serde_json::from_value(serde_json::Value::Object(m)).unwrap(); + assert!(params.encryption_key.is_some()); + } +} diff --git a/src/openhuman/billing/schemas.rs b/src/openhuman/billing/schemas.rs index cf726b3cf2..f4b456fa58 100644 --- a/src/openhuman/billing/schemas.rs +++ b/src/openhuman/billing/schemas.rs @@ -570,3 +570,291 @@ fn output_field(name: &'static str, ty: TypeSchema, comment: &'static str) -> Fi required: true, } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_billing_controller_schemas_returns_15() { + let schemas = all_billing_controller_schemas(); + assert_eq!(schemas.len(), 15); + } + + #[test] + fn all_billing_registered_controllers_returns_15() { + let controllers = all_billing_registered_controllers(); + assert_eq!(controllers.len(), 15); + } + + #[test] + fn billing_schemas_get_current_plan() { + let s = billing_schemas("billing_get_current_plan"); + assert_eq!(s.namespace, "billing"); + assert_eq!(s.function, "get_current_plan"); + assert!(s.inputs.is_empty()); + assert!(!s.outputs.is_empty()); + } + + #[test] + fn billing_schemas_get_balance() { + let s = billing_schemas("billing_get_balance"); + assert_eq!(s.function, "get_balance"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_purchase_plan() { + let s = billing_schemas("billing_purchase_plan"); + assert_eq!(s.function, "purchase_plan"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "plan"); + assert!(s.inputs[0].required); + assert!(s.outputs.len() >= 2); + } + + #[test] + fn billing_schemas_create_portal_session() { + let s = billing_schemas("billing_create_portal_session"); + assert_eq!(s.function, "create_portal_session"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_top_up() { + let s = billing_schemas("billing_top_up"); + assert_eq!(s.function, "top_up"); + assert_eq!(s.inputs.len(), 2); + assert_eq!(s.inputs[0].name, "amountUsd"); + assert!(s.inputs[0].required); + assert!(!s.inputs[1].required); // gateway is optional + } + + #[test] + fn billing_schemas_create_coinbase_charge() { + let s = billing_schemas("billing_create_coinbase_charge"); + assert_eq!(s.function, "create_coinbase_charge"); + assert_eq!(s.inputs.len(), 2); + assert!(s.outputs.len() >= 4); + } + + #[test] + fn billing_schemas_get_transactions() { + let s = billing_schemas("billing_get_transactions"); + assert_eq!(s.function, "get_transactions"); + assert_eq!(s.inputs.len(), 2); + assert!(!s.inputs[0].required); // limit is optional + assert!(!s.inputs[1].required); // offset is optional + } + + #[test] + fn billing_schemas_get_auto_recharge() { + let s = billing_schemas("billing_get_auto_recharge"); + assert_eq!(s.function, "get_auto_recharge"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_update_auto_recharge() { + let s = billing_schemas("billing_update_auto_recharge"); + assert_eq!(s.function, "update_auto_recharge"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "payload"); + } + + #[test] + fn billing_schemas_get_cards() { + let s = billing_schemas("billing_get_cards"); + assert_eq!(s.function, "get_cards"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_create_setup_intent() { + let s = billing_schemas("billing_create_setup_intent"); + assert_eq!(s.function, "create_setup_intent"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_update_card() { + let s = billing_schemas("billing_update_card"); + assert_eq!(s.function, "update_card"); + assert_eq!(s.inputs.len(), 2); + } + + #[test] + fn billing_schemas_delete_card() { + let s = billing_schemas("billing_delete_card"); + assert_eq!(s.function, "delete_card"); + assert_eq!(s.inputs.len(), 1); + } + + #[test] + fn billing_schemas_redeem_coupon() { + let s = billing_schemas("billing_redeem_coupon"); + assert_eq!(s.function, "redeem_coupon"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "code"); + } + + #[test] + fn billing_schemas_get_coupons() { + let s = billing_schemas("billing_get_coupons"); + assert_eq!(s.function, "get_coupons"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn billing_schemas_unknown_function() { + let s = billing_schemas("billing_nonexistent"); + assert_eq!(s.function, "unknown"); + } + + // Param deserialization tests + + #[test] + fn deserialize_purchase_plan_params() { + let params: Map = serde_json::from_value(json!({"plan": "pro"})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + assert_eq!(result.unwrap().plan, "pro"); + } + + #[test] + fn deserialize_top_up_params() { + let params: Map = + serde_json::from_value(json!({"amountUsd": 10.0})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + let p = result.unwrap(); + assert_eq!(p.amount_usd, 10.0); + assert!(p.gateway.is_none()); + } + + #[test] + fn deserialize_top_up_params_with_gateway() { + let params: Map = + serde_json::from_value(json!({"amountUsd": 5.0, "gateway": "stripe"})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + assert_eq!(result.unwrap().gateway.as_deref(), Some("stripe")); + } + + #[test] + fn deserialize_coinbase_charge_params() { + let params: Map = + serde_json::from_value(json!({"plan": "enterprise", "interval": "annual"})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + let p = result.unwrap(); + assert_eq!(p.plan, "enterprise"); + assert_eq!(p.interval.as_deref(), Some("annual")); + } + + #[test] + fn deserialize_transactions_params_defaults() { + let params: Map = serde_json::from_value(json!({})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + let p = result.unwrap(); + assert!(p.limit.is_none()); + assert!(p.offset.is_none()); + } + + #[test] + fn deserialize_transactions_params_with_values() { + let params: Map = + serde_json::from_value(json!({"limit": 10, "offset": 5})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + let p = result.unwrap(); + assert_eq!(p.limit, Some(10)); + assert_eq!(p.offset, Some(5)); + } + + #[test] + fn deserialize_card_params() { + let params: Map = + serde_json::from_value(json!({"paymentMethodId": "pm_123"})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + assert_eq!(result.unwrap().payment_method_id, "pm_123"); + } + + #[test] + fn deserialize_update_card_params() { + let params: Map = serde_json::from_value( + json!({"paymentMethodId": "pm_1", "payload": {"default": true}}), + ) + .unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + } + + #[test] + fn deserialize_redeem_coupon_params() { + let params: Map = serde_json::from_value(json!({"code": "SAVE50"})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_ok()); + assert_eq!(result.unwrap().code, "SAVE50"); + } + + #[test] + fn deserialize_invalid_params_returns_error() { + let params: Map = serde_json::from_value(json!({})).unwrap(); + let result = deserialize_params::(params); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("invalid params")); + } + + // Helper function tests + + #[test] + fn required_string_helper() { + let f = required_string("name", "a comment"); + assert_eq!(f.name, "name"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::String)); + } + + #[test] + fn optional_string_helper() { + let f = optional_string("gateway", "desc"); + assert_eq!(f.name, "gateway"); + assert!(!f.required); + } + + #[test] + fn optional_u64_helper() { + let f = optional_u64("limit", "desc"); + assert_eq!(f.name, "limit"); + assert!(!f.required); + } + + #[test] + fn json_output_helper() { + let f = json_output("result", "desc"); + assert_eq!(f.name, "result"); + assert!(f.required); + } + + #[test] + fn output_field_helper() { + let f = output_field("url", TypeSchema::String, "desc"); + assert_eq!(f.name, "url"); + assert!(f.required); + } + + #[test] + fn schemas_and_controllers_are_consistent() { + let schemas = all_billing_controller_schemas(); + let controllers = all_billing_registered_controllers(); + assert_eq!(schemas.len(), controllers.len()); + for (s, c) in schemas.iter().zip(controllers.iter()) { + assert_eq!(s.namespace, c.schema.namespace); + assert_eq!(s.function, c.schema.function); + } + } +} diff --git a/src/openhuman/composio/providers/gmail/sync.rs b/src/openhuman/composio/providers/gmail/sync.rs index 0fb74140f9..eb26528207 100644 --- a/src/openhuman/composio/providers/gmail/sync.rs +++ b/src/openhuman/composio/providers/gmail/sync.rs @@ -67,3 +67,95 @@ pub(crate) fn now_ms() -> u64 { .map(|d| d.as_millis() as u64) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_messages_from_data_messages() { + let data = json!({"data": {"messages": [{"id": "1"}, {"id": "2"}]}}); + let msgs = extract_messages(&data); + assert_eq!(msgs.len(), 2); + } + + #[test] + fn extract_messages_from_top_level() { + let data = json!({"messages": [{"id": "1"}]}); + let msgs = extract_messages(&data); + assert_eq!(msgs.len(), 1); + } + + #[test] + fn extract_messages_from_data_items() { + let data = json!({"data": {"items": [{"id": "a"}]}}); + let msgs = extract_messages(&data); + assert_eq!(msgs.len(), 1); + } + + #[test] + fn extract_messages_empty_when_no_match() { + let data = json!({"foo": "bar"}); + assert!(extract_messages(&data).is_empty()); + } + + #[test] + fn extract_page_token_from_data() { + let data = json!({"data": {"nextPageToken": "abc123"}}); + assert_eq!(extract_page_token(&data), Some("abc123".into())); + } + + #[test] + fn extract_page_token_from_top_level() { + let data = json!({"nextPageToken": "tok"}); + assert_eq!(extract_page_token(&data), Some("tok".into())); + } + + #[test] + fn extract_page_token_none_when_empty() { + let data = json!({"data": {"nextPageToken": " "}}); + assert_eq!(extract_page_token(&data), None); + } + + #[test] + fn extract_page_token_none_when_missing() { + let data = json!({"data": {}}); + assert_eq!(extract_page_token(&data), None); + } + + #[test] + fn cursor_to_filter_epoch_millis() { + let filter = cursor_to_gmail_after_filter("1700000000000").unwrap(); + assert!(filter.contains('/')); + assert_eq!(filter, "2023/11/14"); + } + + #[test] + fn cursor_to_filter_iso_date() { + let filter = cursor_to_gmail_after_filter("2024-01-15").unwrap(); + assert_eq!(filter, "2024/01/15"); + } + + #[test] + fn cursor_to_filter_rfc3339() { + let filter = cursor_to_gmail_after_filter("2024-06-01T12:00:00Z").unwrap(); + assert_eq!(filter, "2024/06/01"); + } + + #[test] + fn cursor_to_filter_invalid_returns_none() { + assert!(cursor_to_gmail_after_filter("not-a-date").is_none()); + } + + #[test] + fn cursor_to_filter_trims_whitespace() { + let filter = cursor_to_gmail_after_filter(" 2024-01-15 ").unwrap(); + assert_eq!(filter, "2024/01/15"); + } + + #[test] + fn now_ms_returns_nonzero() { + assert!(now_ms() > 0); + } +} diff --git a/src/openhuman/composio/providers/notion/sync.rs b/src/openhuman/composio/providers/notion/sync.rs index 1571b13c87..e8c01e180b 100644 --- a/src/openhuman/composio/providers/notion/sync.rs +++ b/src/openhuman/composio/providers/notion/sync.rs @@ -81,3 +81,113 @@ pub(crate) fn now_ms() -> u64 { .map(|d| d.as_millis() as u64) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_results_from_data_results() { + let data = json!({"data": {"results": [{"id": "page1"}]}}); + let results = extract_results(&data); + assert_eq!(results.len(), 1); + } + + #[test] + fn extract_results_from_top_level() { + let data = json!({"results": [{"id": "a"}, {"id": "b"}]}); + let results = extract_results(&data); + assert_eq!(results.len(), 2); + } + + #[test] + fn extract_results_from_data_items() { + let data = json!({"data": {"items": [{"id": "x"}]}}); + let results = extract_results(&data); + assert_eq!(results.len(), 1); + } + + #[test] + fn extract_results_empty_when_no_match() { + let data = json!({"foo": "bar"}); + assert!(extract_results(&data).is_empty()); + } + + #[test] + fn extract_notion_cursor_from_data() { + let data = json!({"data": {"next_cursor": "cur123"}}); + assert_eq!(extract_notion_cursor(&data), Some("cur123".into())); + } + + #[test] + fn extract_notion_cursor_from_top_level() { + let data = json!({"next_cursor": "abc"}); + assert_eq!(extract_notion_cursor(&data), Some("abc".into())); + } + + #[test] + fn extract_notion_cursor_none_when_empty() { + let data = json!({"data": {"next_cursor": " "}}); + assert_eq!(extract_notion_cursor(&data), None); + } + + #[test] + fn extract_notion_cursor_none_when_missing() { + assert_eq!(extract_notion_cursor(&json!({})), None); + } + + #[test] + fn extract_page_title_from_properties_title_type() { + let page = json!({ + "properties": { + "Name": { + "type": "title", + "title": [{"plain_text": "Hello"}, {"plain_text": " World"}] + } + } + }); + assert_eq!(extract_page_title(&page), Some("Hello World".into())); + } + + #[test] + fn extract_page_title_from_nested_data_properties() { + let page = json!({ + "data": { + "properties": { + "Title": { + "type": "title", + "title": [{"plain_text": "My Page"}] + } + } + } + }); + assert_eq!(extract_page_title(&page), Some("My Page".into())); + } + + #[test] + fn extract_page_title_fallback_to_top_level_title() { + let page = json!({"title": "Fallback Title"}); + assert_eq!(extract_page_title(&page), Some("Fallback Title".into())); + } + + #[test] + fn extract_page_title_none_when_empty() { + let page = json!({"properties": {"Name": {"type": "title", "title": []}}}); + // Empty title array means no text + assert!( + extract_page_title(&page).is_none() || extract_page_title(&page) == Some(String::new()) + ); + } + + #[test] + fn extract_page_title_none_when_no_title_field() { + let page = json!({"id": "123"}); + assert!(extract_page_title(&page).is_none()); + } + + #[test] + fn now_ms_returns_nonzero() { + assert!(now_ms() > 0); + } +} diff --git a/src/openhuman/config/schema/channels.rs b/src/openhuman/config/schema/channels.rs index 526a21c4df..76fa98d5db 100644 --- a/src/openhuman/config/schema/channels.rs +++ b/src/openhuman/config/schema/channels.rs @@ -441,6 +441,170 @@ mod tests { assert!(!config.mention_only); } + #[test] + fn default_channels_config_has_no_integrations() { + let cfg = ChannelsConfig::default(); + assert!(cfg.cli); + assert!(!cfg.has_listening_integrations()); + assert_eq!(cfg.message_timeout_secs, 300); + assert!(cfg.active_channel.is_none()); + } + + #[test] + fn has_listening_integrations_detects_telegram() { + let mut cfg = ChannelsConfig::default(); + cfg.telegram = Some(TelegramConfig { + bot_token: "tok".into(), + allowed_users: vec![], + stream_mode: StreamMode::Off, + draft_update_interval_ms: 1000, + mention_only: false, + }); + assert!(cfg.has_listening_integrations()); + } + + #[test] + fn has_listening_integrations_detects_discord() { + let mut cfg = ChannelsConfig::default(); + cfg.discord = Some(DiscordConfig { + bot_token: "tok".into(), + guild_id: None, + channel_id: None, + allowed_users: vec![], + listen_to_bots: false, + mention_only: false, + }); + assert!(cfg.has_listening_integrations()); + } + + #[test] + fn has_listening_integrations_detects_slack() { + let mut cfg = ChannelsConfig::default(); + cfg.slack = Some(SlackConfig { + bot_token: "tok".into(), + app_token: None, + channel_id: None, + allowed_users: vec![], + }); + assert!(cfg.has_listening_integrations()); + } + + #[test] + fn stream_mode_default_is_off() { + assert_eq!(StreamMode::default(), StreamMode::Off); + } + + #[test] + fn stream_mode_serde_roundtrip() { + let json = serde_json::to_string(&StreamMode::Partial).unwrap(); + let back: StreamMode = serde_json::from_str(&json).unwrap(); + assert_eq!(back, StreamMode::Partial); + } + + fn empty_whatsapp() -> WhatsAppConfig { + WhatsAppConfig { + access_token: None, + phone_number_id: None, + verify_token: None, + app_secret: None, + session_path: None, + pair_phone: None, + pair_code: None, + allowed_numbers: vec![], + } + } + + #[test] + fn whatsapp_backend_type_cloud_when_phone_number_id() { + let mut cfg = empty_whatsapp(); + cfg.phone_number_id = Some("123".into()); + assert_eq!(cfg.backend_type(), "cloud"); + } + + #[test] + fn whatsapp_backend_type_web_when_session_path() { + let mut cfg = empty_whatsapp(); + cfg.session_path = Some("/tmp/session".into()); + assert_eq!(cfg.backend_type(), "web"); + } + + #[test] + fn whatsapp_backend_type_defaults_to_cloud() { + let cfg = empty_whatsapp(); + assert_eq!(cfg.backend_type(), "cloud"); + } + + #[test] + fn whatsapp_is_cloud_config_requires_all_three() { + let mut cfg = empty_whatsapp(); + cfg.phone_number_id = Some("123".into()); + cfg.access_token = Some("tok".into()); + cfg.verify_token = Some("vtok".into()); + assert!(cfg.is_cloud_config()); + + let mut incomplete = empty_whatsapp(); + incomplete.phone_number_id = Some("123".into()); + assert!(!incomplete.is_cloud_config()); + } + + #[test] + fn whatsapp_is_web_config() { + let mut cfg = empty_whatsapp(); + cfg.session_path = Some("/path".into()); + assert!(cfg.is_web_config()); + assert!(!empty_whatsapp().is_web_config()); + } + + #[test] + fn security_config_defaults() { + let sec = SecurityConfig::default(); + assert!(sec.audit.enabled); + assert_eq!(sec.audit.log_path, "audit.log"); + assert_eq!(sec.audit.max_size_mb, 100); + assert!(!sec.audit.sign_events); + assert_eq!(sec.resources.max_memory_mb, 512); + assert_eq!(sec.resources.max_cpu_time_seconds, 60); + assert_eq!(sec.resources.max_subprocesses, 10); + assert!(sec.resources.memory_monitoring); + } + + #[test] + fn sandbox_config_default() { + let sb = SandboxConfig::default(); + assert!(sb.enabled.is_none()); + assert!(matches!(sb.backend, SandboxBackend::Auto)); + assert!(sb.firejail_args.is_empty()); + } + + #[test] + fn lark_receive_mode_default_is_websocket() { + assert_eq!(LarkReceiveMode::default(), LarkReceiveMode::Websocket); + } + + #[test] + fn default_irc_port_is_6697() { + let toml = r#" + server = "irc.libera.chat" + nickname = "bot" + "#; + let cfg: IrcConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.port, 6697); + } + + #[test] + fn default_draft_update_interval_ms_is_1000() { + assert_eq!(default_draft_update_interval_ms(), 1000); + } + + #[test] + fn channels_config_serde_roundtrip() { + let cfg = ChannelsConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: ChannelsConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.message_timeout_secs, 300); + assert!(back.cli); + } + #[test] fn discord_config_roundtrip_json() { let config = DiscordConfig { diff --git a/src/openhuman/config/schema/identity_cost.rs b/src/openhuman/config/schema/identity_cost.rs index ae754eee42..0349783e9c 100644 --- a/src/openhuman/config/schema/identity_cost.rs +++ b/src/openhuman/config/schema/identity_cost.rs @@ -140,3 +140,109 @@ impl Default for PeripheralBoardConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cost_config_defaults() { + let c = CostConfig::default(); + assert!(!c.enabled); + assert_eq!(c.daily_limit_usd, 10.0); + assert_eq!(c.monthly_limit_usd, 100.0); + assert_eq!(c.warn_at_percent, 80); + assert!(!c.allow_override); + assert!(!c.prices.is_empty()); + } + + #[test] + fn cost_config_default_pricing_has_known_models() { + let c = CostConfig::default(); + assert!(c.prices.len() >= 3); + } + + #[test] + fn cost_config_serde_roundtrip() { + let c = CostConfig::default(); + let json = serde_json::to_string(&c).unwrap(); + let back: CostConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.daily_limit_usd, 10.0); + assert_eq!(back.monthly_limit_usd, 100.0); + } + + #[test] + fn cost_config_toml_with_custom_values() { + let toml = r#" + enabled = true + daily_limit_usd = 50.0 + monthly_limit_usd = 500.0 + warn_at_percent = 90 + allow_override = true + "#; + let c: CostConfig = toml::from_str(toml).unwrap(); + assert!(c.enabled); + assert_eq!(c.daily_limit_usd, 50.0); + assert_eq!(c.monthly_limit_usd, 500.0); + assert_eq!(c.warn_at_percent, 90); + assert!(c.allow_override); + } + + #[test] + fn model_pricing_defaults_to_zero() { + let p: ModelPricing = serde_json::from_str("{}").unwrap(); + assert_eq!(p.input, 0.0); + assert_eq!(p.output, 0.0); + } + + #[test] + fn peripherals_config_defaults() { + let p = PeripheralsConfig::default(); + assert!(!p.enabled); + assert!(p.boards.is_empty()); + assert!(p.datasheet_dir.is_none()); + } + + #[test] + fn peripheral_board_config_defaults() { + let b = PeripheralBoardConfig::default(); + assert_eq!(b.transport, "serial"); + assert_eq!(b.baud, 115_200); + assert!(b.board.is_empty()); + assert!(b.path.is_none()); + } + + #[test] + fn peripheral_board_config_toml() { + let toml = r#" + board = "esp32" + transport = "usb" + path = "/dev/ttyUSB0" + baud = 9600 + "#; + let b: PeripheralBoardConfig = toml::from_str(toml).unwrap(); + assert_eq!(b.board, "esp32"); + assert_eq!(b.transport, "usb"); + assert_eq!(b.path.as_deref(), Some("/dev/ttyUSB0")); + assert_eq!(b.baud, 9600); + } + + #[test] + fn peripherals_config_serde_roundtrip() { + let p = PeripheralsConfig { + enabled: true, + boards: vec![PeripheralBoardConfig { + board: "arduino".into(), + transport: "serial".into(), + path: Some("/dev/cu.usbmodem".into()), + baud: 115_200, + }], + datasheet_dir: Some("/tmp/sheets".into()), + }; + let json = serde_json::to_string(&p).unwrap(); + let back: PeripheralsConfig = serde_json::from_str(&json).unwrap(); + assert!(back.enabled); + assert_eq!(back.boards.len(), 1); + assert_eq!(back.boards[0].board, "arduino"); + } +} diff --git a/src/openhuman/config/schema/proxy.rs b/src/openhuman/config/schema/proxy.rs index bef325417c..12fbb71029 100644 --- a/src/openhuman/config/schema/proxy.rs +++ b/src/openhuman/config/schema/proxy.rs @@ -670,4 +670,247 @@ mod tests { let err = validate_proxy_url("x", "not a url").unwrap_err(); assert!(err.to_string().to_lowercase().contains("invalid")); } + + // ── ProxyConfig::validate ───────────────────────────────────── + + #[test] + fn validate_disabled_proxy_always_ok() { + let c = ProxyConfig::default(); + assert!(c.validate().is_ok()); + } + + #[test] + fn validate_enabled_without_url_fails() { + let c = ProxyConfig { + enabled: true, + ..Default::default() + }; + let err = c.validate().unwrap_err(); + assert!(err.to_string().contains("no proxy URL")); + } + + #[test] + fn validate_enabled_with_url_ok() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://proxy:8080".into()), + ..Default::default() + }; + assert!(c.validate().is_ok()); + } + + #[test] + fn validate_services_scope_empty_services_fails() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://proxy:8080".into()), + scope: ProxyScope::Services, + services: vec![], + ..Default::default() + }; + let err = c.validate().unwrap_err(); + assert!(err.to_string().contains("non-empty")); + } + + #[test] + fn validate_services_scope_with_valid_services_ok() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://proxy:8080".into()), + scope: ProxyScope::Services, + services: vec!["provider.openai".into()], + ..Default::default() + }; + assert!(c.validate().is_ok()); + } + + #[test] + fn validate_unsupported_service_selector_fails() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://proxy:8080".into()), + scope: ProxyScope::Services, + services: vec!["not.a.valid.selector".into()], + ..Default::default() + }; + let err = c.validate().unwrap_err(); + assert!(err.to_string().contains("Unsupported")); + } + + #[test] + fn validate_bad_proxy_url_fails() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("ftp://bad:21".into()), + ..Default::default() + }; + let err = c.validate().unwrap_err(); + assert!(err.to_string().contains("Invalid")); + } + + // ── should_apply_to_service ─────────────────────────────────── + + #[test] + fn should_apply_disabled_always_false() { + let c = ProxyConfig::default(); + assert!(!c.should_apply_to_service("anything")); + } + + #[test] + fn should_apply_environment_scope_always_false() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://p:8080".into()), + scope: ProxyScope::Environment, + ..Default::default() + }; + assert!(!c.should_apply_to_service("provider.openai")); + } + + #[test] + fn should_apply_openhuman_scope_always_true() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://p:8080".into()), + scope: ProxyScope::OpenHuman, + ..Default::default() + }; + assert!(c.should_apply_to_service("provider.openai")); + assert!(c.should_apply_to_service("anything")); + } + + #[test] + fn should_apply_services_scope_matches_exact() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://p:8080".into()), + scope: ProxyScope::Services, + services: vec!["provider.openai".into()], + ..Default::default() + }; + assert!(c.should_apply_to_service("provider.openai")); + assert!(!c.should_apply_to_service("provider.anthropic")); + } + + #[test] + fn should_apply_services_scope_matches_wildcard() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://p:8080".into()), + scope: ProxyScope::Services, + services: vec!["provider.*".into()], + ..Default::default() + }; + assert!(c.should_apply_to_service("provider.openai")); + assert!(c.should_apply_to_service("provider.anthropic")); + assert!(!c.should_apply_to_service("channel.telegram")); + } + + #[test] + fn should_apply_services_scope_empty_key_returns_false() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://p:8080".into()), + scope: ProxyScope::Services, + services: vec!["provider.*".into()], + ..Default::default() + }; + assert!(!c.should_apply_to_service(" ")); + } + + // ── runtime_proxy_cache_key ─────────────────────────────────── + + #[test] + fn runtime_proxy_cache_key_with_timeouts() { + let key = runtime_proxy_cache_key("provider.openai", Some(30), Some(10)); + assert_eq!(key, "provider.openai|timeout=30|connect_timeout=10"); + } + + #[test] + fn runtime_proxy_cache_key_without_timeouts() { + let key = runtime_proxy_cache_key("provider.openai", None, None); + assert_eq!(key, "provider.openai|timeout=none|connect_timeout=none"); + } + + #[test] + fn runtime_proxy_cache_key_trims_and_lowercases() { + let key = runtime_proxy_cache_key(" Provider.OpenAI ", None, None); + assert!(key.starts_with("provider.openai")); + } + + // ── ProxyConfig::normalized_services / normalized_no_proxy ──── + + #[test] + fn normalized_services_dedup_and_sort() { + let c = ProxyConfig { + services: vec![ + "provider.openai,provider.anthropic".into(), + "provider.openai".into(), + ], + ..Default::default() + }; + let norm = c.normalized_services(); + assert_eq!(norm, vec!["provider.anthropic", "provider.openai"]); + } + + #[test] + fn normalized_no_proxy_dedup_and_sort() { + let c = ProxyConfig { + no_proxy: vec!["localhost,127.0.0.1".into(), "localhost".into()], + ..Default::default() + }; + let norm = c.normalized_no_proxy(); + assert_eq!(norm, vec!["127.0.0.1", "localhost"]); + } + + // ── apply_to_reqwest_builder ───────────────────────────────── + + #[test] + fn apply_to_reqwest_builder_skips_when_not_applicable() { + let c = ProxyConfig::default(); // disabled + let builder = reqwest::Client::builder(); + // Should just return the builder unchanged (no panic) + let _builder = c.apply_to_reqwest_builder(builder, "anything"); + } + + #[test] + fn apply_to_reqwest_builder_applies_all_proxy() { + let c = ProxyConfig { + enabled: true, + all_proxy: Some("http://proxy:8080".into()), + scope: ProxyScope::OpenHuman, + ..Default::default() + }; + let builder = reqwest::Client::builder(); + let builder = c.apply_to_reqwest_builder(builder, "provider.openai"); + // Should build successfully + let client = builder.build(); + assert!(client.is_ok()); + } + + #[test] + fn apply_to_reqwest_builder_applies_http_and_https_proxy() { + let c = ProxyConfig { + enabled: true, + http_proxy: Some("http://proxy:8080".into()), + https_proxy: Some("http://proxy:8443".into()), + scope: ProxyScope::OpenHuman, + ..Default::default() + }; + let builder = reqwest::Client::builder(); + let builder = c.apply_to_reqwest_builder(builder, "test"); + assert!(builder.build().is_ok()); + } + + // ── supported_service_keys / selectors ───────────────────────── + + #[test] + fn supported_service_keys_is_nonempty() { + assert!(!ProxyConfig::supported_service_keys().is_empty()); + } + + #[test] + fn supported_service_selectors_is_nonempty() { + assert!(!ProxyConfig::supported_service_selectors().is_empty()); + } } diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index e4a8ab3c63..e2b5281340 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -854,4 +854,143 @@ mod tests { // Also verify the function itself succeeds. assert!(deliver_if_configured(&config, &job, "hello").await.is_ok()); } + + #[test] + fn is_one_shot_auto_delete_true_for_at_schedule_with_flag() { + let mut job = test_job("echo hi"); + job.delete_after_run = true; + job.schedule = Schedule::At { at: Utc::now() }; + assert!(is_one_shot_auto_delete(&job)); + } + + #[test] + fn is_one_shot_auto_delete_false_for_cron_schedule() { + let mut job = test_job("echo hi"); + job.delete_after_run = true; + job.schedule = Schedule::Cron { + expr: "0 * * * *".into(), + tz: None, + }; + assert!(!is_one_shot_auto_delete(&job)); + } + + #[test] + fn is_one_shot_auto_delete_false_when_flag_not_set() { + let mut job = test_job("echo hi"); + job.delete_after_run = false; + job.schedule = Schedule::At { at: Utc::now() }; + assert!(!is_one_shot_auto_delete(&job)); + } + + #[test] + fn is_env_assignment_true() { + assert!(is_env_assignment("FOO=bar")); + assert!(is_env_assignment("_VAR=1")); + } + + #[test] + fn is_env_assignment_false() { + assert!(!is_env_assignment("echo")); + assert!(!is_env_assignment("=bad")); + assert!(!is_env_assignment("123=nope")); + assert!(!is_env_assignment("")); + } + + #[test] + fn strip_wrapping_quotes_removes_quotes() { + assert_eq!(strip_wrapping_quotes("\"hello\""), "hello"); + assert_eq!(strip_wrapping_quotes("'world'"), "world"); + assert_eq!(strip_wrapping_quotes("noquotes"), "noquotes"); + assert_eq!(strip_wrapping_quotes(""), ""); + } + + #[test] + fn forbidden_path_argument_allows_safe_commands() { + let policy = SecurityPolicy::default(); + assert!(forbidden_path_argument(&policy, "echo hello").is_none()); + assert!(forbidden_path_argument(&policy, "date").is_none()); + } + + #[test] + fn forbidden_path_argument_skips_flags_and_urls() { + let policy = SecurityPolicy::default(); + assert!(forbidden_path_argument(&policy, "curl https://example.com").is_none()); + assert!(forbidden_path_argument(&policy, "ls -la").is_none()); + } + + #[test] + fn warn_if_high_frequency_agent_job_does_not_panic_on_non_agent() { + let mut job = test_job("echo hi"); + job.job_type = JobType::Shell; + warn_if_high_frequency_agent_job(&job); // should not panic + } + + #[test] + fn warn_if_high_frequency_agent_job_does_not_panic_on_at_schedule() { + let mut job = test_job("echo hi"); + job.job_type = JobType::Agent; + job.schedule = Schedule::At { at: Utc::now() }; + warn_if_high_frequency_agent_job(&job); // should not panic + } + + #[test] + fn warn_if_high_frequency_agent_job_handles_every_ms() { + let mut job = test_job("echo hi"); + job.job_type = JobType::Agent; + job.schedule = Schedule::Every { every_ms: 60_000 }; // 1 minute — too frequent + warn_if_high_frequency_agent_job(&job); // should warn but not panic + } + + #[tokio::test] + async fn deliver_if_configured_skips_empty_mode() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + let mut job = test_job("echo ok"); + job.delivery.mode = "".into(); + assert!(deliver_if_configured(&config, &job, "output").await.is_ok()); + } + + #[tokio::test] + async fn deliver_if_configured_announce_missing_channel_errors() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + let mut job = test_job("echo ok"); + job.delivery = DeliveryConfig { + mode: "announce".into(), + channel: None, + to: Some("target".into()), + best_effort: true, + }; + let result = deliver_if_configured(&config, &job, "out").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn deliver_if_configured_announce_missing_target_errors() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + let mut job = test_job("echo ok"); + job.delivery = DeliveryConfig { + mode: "announce".into(), + channel: Some("telegram".into()), + to: None, + best_effort: true, + }; + let result = deliver_if_configured(&config, &job, "out").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn deliver_if_configured_proactive_mode_succeeds() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + let mut job = test_job("echo ok"); + job.delivery = DeliveryConfig { + mode: "proactive".into(), + channel: None, + to: None, + best_effort: true, + }; + assert!(deliver_if_configured(&config, &job, "hello").await.is_ok()); + } } diff --git a/src/openhuman/health/schemas.rs b/src/openhuman/health/schemas.rs index 2ac37d43c6..4ad769b654 100644 --- a/src/openhuman/health/schemas.rs +++ b/src/openhuman/health/schemas.rs @@ -51,3 +51,54 @@ fn handle_snapshot(_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_one() { + assert_eq!(all_controller_schemas().len(), 1); + } + + #[test] + fn all_controllers_returns_one() { + assert_eq!(all_registered_controllers().len(), 1); + } + + #[test] + fn snapshot_schema() { + let s = schemas("snapshot"); + assert_eq!(s.namespace, "health"); + assert_eq!(s.function, "snapshot"); + assert!(s.inputs.is_empty()); + assert!(!s.outputs.is_empty()); + } + + #[test] + fn unknown_function_returns_unknown() { + let s = schemas("bad"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "health"); + } + + #[test] + fn schemas_and_controllers_match() { + let s = all_controller_schemas(); + let c = all_registered_controllers(); + assert_eq!(s[0].function, c[0].schema.function); + } + + #[tokio::test] + async fn handle_snapshot_returns_json_object() { + let result = handle_snapshot(Map::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_object()); + } + + #[test] + fn to_json_helper() { + let outcome = RpcOutcome::single_log(serde_json::json!({"ok": true}), "log"); + assert!(to_json(outcome).is_ok()); + } +} diff --git a/src/openhuman/local_ai/device.rs b/src/openhuman/local_ai/device.rs index f1f2db4896..371a6fa0cf 100644 --- a/src/openhuman/local_ai/device.rs +++ b/src/openhuman/local_ai/device.rs @@ -201,6 +201,55 @@ mod tests { assert_eq!(desc.as_deref(), Some("Intel Mac (no Metal GPU)")); } + #[test] + fn detect_gpu_no_gpu_on_linux_without_nvidia() { + // Linux without nvidia-smi should report no GPU (or NVIDIA if nvidia-smi is present). + // Since we can't mock nvidia-smi here, we at least verify the function doesn't panic. + let (has, desc) = detect_gpu("AMD Ryzen 9", "Linux"); + // On CI/dev machines without nvidia-smi, this should be (false, None). + // If nvidia-smi is present, it returns (true, Some("NVIDIA ...")), which is also fine. + if !has { + assert!(desc.is_none()); + } + } + + #[test] + fn detect_gpu_windows_without_nvidia() { + let (has, desc) = detect_gpu("Intel Core i9", "Windows"); + // Same as Linux: depends on nvidia-smi availability + if !has { + assert!(desc.is_none()); + } + } + + #[test] + fn total_ram_gb_exact_boundary() { + let profile = DeviceProfile { + total_ram_bytes: 1024 * 1024 * 1024, // exactly 1 GiB + cpu_count: 1, + cpu_brand: "x".into(), + os_name: "x".into(), + os_version: "1".into(), + has_gpu: false, + gpu_description: None, + }; + assert_eq!(profile.total_ram_gb(), 1); + } + + #[test] + fn total_ram_gb_zero_bytes() { + let profile = DeviceProfile { + total_ram_bytes: 0, + cpu_count: 1, + cpu_brand: "x".into(), + os_name: "x".into(), + os_version: "1".into(), + has_gpu: false, + gpu_description: None, + }; + assert_eq!(profile.total_ram_gb(), 0); + } + #[test] fn device_profile_serde_round_trip() { let original = DeviceProfile { diff --git a/src/openhuman/screen_intelligence/schemas.rs b/src/openhuman/screen_intelligence/schemas.rs index 91d03f47e3..ab6845de42 100644 --- a/src/openhuman/screen_intelligence/schemas.rs +++ b/src/openhuman/screen_intelligence/schemas.rs @@ -467,4 +467,118 @@ mod tests { assert!(!h.schema.function.is_empty()); } } + + #[test] + fn status_schema_has_no_inputs() { + let s = schemas("status"); + assert!(s.inputs.is_empty()); + assert_eq!(s.outputs.len(), 1); + } + + #[test] + fn request_permissions_schema_has_no_inputs() { + assert!(schemas("request_permissions").inputs.is_empty()); + } + + #[test] + fn request_permission_requires_permission_input() { + let s = schemas("request_permission"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "permission"); + assert!(s.inputs[0].required); + } + + #[test] + fn refresh_permissions_schema_has_no_inputs() { + assert!(schemas("refresh_permissions").inputs.is_empty()); + } + + #[test] + fn start_session_schema_requires_consent() { + let s = schemas("start_session"); + let consent = s.inputs.iter().find(|f| f.name == "consent").unwrap(); + assert!(consent.required); + } + + #[test] + fn stop_session_schema_has_optional_reason() { + let s = schemas("stop_session"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "reason"); + assert!(!s.inputs[0].required); + } + + #[test] + fn capture_now_schema_has_optional_inputs() { + let s = schemas("capture_now"); + for input in &s.inputs { + assert!( + !input.required, + "capture_now input '{}' should be optional", + input.name + ); + } + } + + #[test] + fn capture_image_ref_schema_has_no_inputs() { + let s = schemas("capture_image_ref"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn input_action_schema_requires_action() { + let s = schemas("input_action"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"action")); + } + + #[test] + fn vision_recent_schema() { + let s = schemas("vision_recent"); + assert!(!s.description.is_empty()); + } + + #[test] + fn vision_flush_schema_has_no_inputs() { + assert!(schemas("vision_flush").inputs.is_empty()); + } + + #[test] + fn capture_test_schema() { + let s = schemas("capture_test"); + assert_eq!(s.function, "capture_test"); + } + + #[test] + fn globe_listener_start_schema() { + let s = schemas("globe_listener_start"); + assert_eq!(s.function, "globe_listener_start"); + } + + #[test] + fn globe_listener_poll_schema() { + let s = schemas("globe_listener_poll"); + assert_eq!(s.function, "globe_listener_poll"); + } + + #[test] + fn globe_listener_stop_schema() { + let s = schemas("globe_listener_stop"); + assert_eq!(s.function, "globe_listener_stop"); + } + + #[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); + } + } } diff --git a/src/openhuman/text_input/schemas.rs b/src/openhuman/text_input/schemas.rs index d63490b28d..bf007f529f 100644 --- a/src/openhuman/text_input/schemas.rs +++ b/src/openhuman/text_input/schemas.rs @@ -241,3 +241,158 @@ fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_controller_schemas_returns_5() { + assert_eq!(all_controller_schemas().len(), 5); + } + + #[test] + fn all_registered_controllers_returns_5() { + assert_eq!(all_registered_controllers().len(), 5); + } + + #[test] + fn schemas_and_controllers_are_consistent() { + 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.namespace, ctrl.schema.namespace); + assert_eq!(schema.function, ctrl.schema.function); + } + } + + #[test] + fn all_schemas_use_text_input_namespace() { + for s in all_controller_schemas() { + assert_eq!(s.namespace, "text_input"); + assert!(!s.description.is_empty()); + assert!(!s.outputs.is_empty()); + } + } + + #[test] + fn read_field_schema() { + let s = schemas("read_field"); + assert_eq!(s.function, "read_field"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "include_bounds"); + assert!(!s.inputs[0].required); + } + + #[test] + fn insert_text_schema() { + let s = schemas("insert_text"); + assert_eq!(s.function, "insert_text"); + assert_eq!(s.inputs.len(), 4); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert_eq!(required, vec!["text"]); + } + + #[test] + fn show_ghost_schema() { + let s = schemas("show_ghost"); + assert_eq!(s.function, "show_ghost"); + assert_eq!(s.inputs.len(), 3); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert_eq!(required, vec!["text"]); + } + + #[test] + fn dismiss_ghost_schema() { + let s = schemas("dismiss_ghost"); + assert_eq!(s.function, "dismiss_ghost"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn accept_ghost_schema() { + let s = schemas("accept_ghost"); + assert_eq!(s.function, "accept_ghost"); + assert_eq!(s.inputs.len(), 4); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert_eq!(required, vec!["text"]); + } + + #[test] + fn unknown_function_returns_fallback() { + let s = schemas("nonexistent"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "text_input"); + } + + #[test] + fn deserialize_params_valid() { + let mut m = Map::new(); + m.insert("tunnel_uuid".into(), Value::String("x".into())); + // Just test the generic helper works on a simple struct + #[derive(serde::Deserialize)] + struct Simple { + tunnel_uuid: String, + } + let result = deserialize_params::(m); + assert!(result.is_ok()); + } + + #[test] + fn deserialize_params_invalid() { + let err = + deserialize_params::(Map::new()).unwrap_err(); + assert!(err.contains("invalid params")); + } + + #[test] + fn deserialize_params_or_default_empty_returns_default() { + let result = + deserialize_params_or_default::(Map::new()); + // Should be default value, not panic + let _ = result; + } + + #[test] + fn deserialize_params_or_default_invalid_returns_default() { + let mut m = Map::new(); + m.insert( + "bad_field".into(), + Value::Number(serde_json::Number::from(42)), + ); + let result = deserialize_params_or_default::(m); + let _ = result; + } + + #[test] + fn json_output_helper() { + let f = json_output("result", "desc"); + assert_eq!(f.name, "result"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::Json)); + } + + #[test] + fn to_json_helper() { + let outcome = RpcOutcome::single_log(json!({"ok": true}), "log"); + let result = to_json(outcome); + assert!(result.is_ok()); + } +} diff --git a/src/openhuman/webhooks/router.rs b/src/openhuman/webhooks/router.rs index e4f83f71be..af0b485ff4 100644 --- a/src/openhuman/webhooks/router.rs +++ b/src/openhuman/webhooks/router.rs @@ -636,4 +636,210 @@ mod tests { assert_eq!(router.clear_logs(), 1); assert!(router.list_logs(Some(10)).is_empty()); } + + #[test] + fn register_echo_and_route_returns_none_for_echo_targets() { + let router = WebhookRouter::new(None); + router + .register_echo("uuid-echo", Some("Test Echo".into()), None) + .unwrap(); + // Echo targets are target_kind="echo", route() only returns "skill" targets + assert_eq!(router.route("uuid-echo"), None); + } + + #[test] + fn registration_returns_full_tunnel_info() { + let router = WebhookRouter::new(None); + router + .register( + "uuid-1", + "gmail", + Some("My Tunnel".into()), + Some("bt-1".into()), + ) + .unwrap(); + let reg = router.registration("uuid-1").unwrap(); + assert_eq!(reg.tunnel_uuid, "uuid-1"); + assert_eq!(reg.skill_id, "gmail"); + assert_eq!(reg.tunnel_name.as_deref(), Some("My Tunnel")); + assert_eq!(reg.backend_tunnel_id.as_deref(), Some("bt-1")); + } + + #[test] + fn registration_returns_none_for_missing_uuid() { + let router = WebhookRouter::new(None); + assert!(router.registration("no-such").is_none()); + } + + #[test] + fn list_all_returns_all_registrations() { + let router = WebhookRouter::new(None); + router.register("u1", "s1", None, None).unwrap(); + router.register("u2", "s2", None, None).unwrap(); + let all = router.list_all(); + assert_eq!(all.len(), 2); + } + + #[test] + fn list_logs_respects_limit() { + let router = WebhookRouter::new(None); + for i in 0..5 { + router.record_parse_error( + format!("corr-{i}"), + None, + None, + None, + json!({}), + "error".into(), + ); + } + let logs = router.list_logs(Some(3)); + assert_eq!(logs.len(), 3); + } + + #[test] + fn list_logs_default_limit() { + let router = WebhookRouter::new(None); + for i in 0..5 { + router.record_parse_error( + format!("corr-{i}"), + None, + None, + None, + json!({}), + "err".into(), + ); + } + let logs = router.list_logs(None); + assert_eq!(logs.len(), 5); // less than default limit of 100 + } + + #[test] + fn record_response_without_prior_request_creates_new_entry() { + let router = WebhookRouter::new(None); + let request = WebhookRequest { + correlation_id: "corr-new".into(), + tunnel_id: "tid".into(), + tunnel_uuid: "uuid-new".into(), + tunnel_name: "Test".into(), + method: "POST".into(), + path: "/test".into(), + headers: HashMap::new(), + query: HashMap::new(), + body: String::new(), + }; + let response = WebhookResponseData { + correlation_id: "corr-new".into(), + status_code: 200, + headers: HashMap::new(), + body: "ok".into(), + }; + // No prior record_request — should still create a log entry + router.record_response(&request, &response, None, None); + let logs = router.list_logs(Some(10)); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].stage, "completed"); + } + + #[test] + fn record_response_with_error_sets_error_stage() { + let router = WebhookRouter::new(None); + let request = WebhookRequest { + correlation_id: "corr-err".into(), + tunnel_id: "tid".into(), + tunnel_uuid: "uuid-err".into(), + tunnel_name: "Test".into(), + method: "POST".into(), + path: "/test".into(), + headers: HashMap::new(), + query: HashMap::new(), + body: String::new(), + }; + let response = WebhookResponseData { + correlation_id: "corr-err".into(), + status_code: 500, + headers: HashMap::new(), + body: String::new(), + }; + router.record_request(&request, None); + router.record_response(&request, &response, None, Some("handler crashed".into())); + let logs = router.list_logs(Some(10)); + assert_eq!(logs[0].stage, "error"); + assert_eq!(logs[0].error_message.as_deref(), Some("handler crashed")); + } + + #[test] + fn clear_logs_returns_zero_when_empty() { + let router = WebhookRouter::new(None); + assert_eq!(router.clear_logs(), 0); + } + + #[test] + fn subscribe_debug_events_does_not_panic() { + let router = WebhookRouter::new(None); + let _rx = router.subscribe_debug_events(); + } + + #[test] + fn persist_and_load_roundtrip() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + + let router = WebhookRouter::new(Some(path.clone())); + router + .register("uuid-p1", "skill-a", Some("Tunnel A".into()), None) + .unwrap(); + router + .register("uuid-p2", "skill-b", None, Some("bt-2".into())) + .unwrap(); + + // Load from disk + let router2 = WebhookRouter::new(Some(path)); + assert_eq!(router2.list_all().len(), 2); + assert!(router2.registration("uuid-p1").is_some()); + assert!(router2.registration("uuid-p2").is_some()); + } + + #[test] + fn unregister_nonexistent_tunnel_is_noop() { + let router = WebhookRouter::new(None); + // Should not error even though tunnel doesn't exist + router.unregister("no-such", "any-skill").unwrap(); + } + + #[test] + fn unregister_skill_with_no_tunnels_is_noop() { + let router = WebhookRouter::new(None); + router.register("u1", "other", None, None).unwrap(); + router.unregister_skill("nonexistent"); + assert_eq!(router.list_all().len(), 1); + } + + #[test] + fn record_parse_error_creates_entry_with_parse_error_stage() { + let router = WebhookRouter::new(None); + router.record_parse_error( + "corr-p".into(), + Some("uuid-p".into()), + Some("GET".into()), + Some("/bad".into()), + json!({"raw": true}), + "malformed body".into(), + ); + let logs = router.list_logs(Some(1)); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].stage, "parse_error"); + assert_eq!(logs[0].status_code, Some(400)); + assert_eq!(logs[0].error_message.as_deref(), Some("malformed body")); + } + + #[test] + fn truncate_logs_respects_max() { + let router = WebhookRouter::new(None); + for i in 0..(MAX_DEBUG_LOG_ENTRIES + 10) { + router.record_parse_error(format!("c-{i}"), None, None, None, json!({}), "e".into()); + } + let logs = router.list_logs(Some(MAX_DEBUG_LOG_ENTRIES + 100)); + assert!(logs.len() <= MAX_DEBUG_LOG_ENTRIES); + } } diff --git a/src/rpc/dispatch.rs b/src/rpc/dispatch.rs index 40f2d4461b..6fdd335e5a 100644 --- a/src/rpc/dispatch.rs +++ b/src/rpc/dispatch.rs @@ -53,4 +53,24 @@ mod tests { let result = try_dispatch("nonexistent.method", json!({})).await; assert!(result.is_none(), "unknown methods should return None"); } + + #[tokio::test] + async fn dispatch_security_policy_info_returns_some() { + let result = try_dispatch("openhuman.security_policy_info", json!({})).await; + assert!(result.is_some(), "security_policy_info should be handled"); + let inner = result.unwrap(); + assert!(inner.is_ok(), "security_policy_info should succeed"); + } + + #[tokio::test] + async fn dispatch_empty_method_returns_none() { + let result = try_dispatch("", json!({})).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn dispatch_close_but_wrong_method_returns_none() { + let result = try_dispatch("openhuman.security_policy", json!({})).await; + assert!(result.is_none()); + } } From 2f033a7ebf127292d493ca91543e6fe009d773ca Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 16 Apr 2026 18:54:54 +0530 Subject: [PATCH 2/3] =?UTF-8?q?test(coverage):=20batch=209=E2=80=9310=20?= =?UTF-8?q?=E2=80=94=20browser=5Fopen,=20update=5Fmemory=5Fmd,=20credentia?= =?UTF-8?q?ls=20profiles,=20cost=20tracker=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 53 new tests across 4 more modules: - browser_open: IPv4/IPv6 private ranges, host matching, normalize_domain edges - update_memory_md: empty file, section creation, unknown action, param validation - credentials/profiles: token expiry, CRUD operations, active profile management - cost/tracker: budget warnings, monthly exceeded, model stats aggregation All 4027 tests pass. --- src/openhuman/cost/tracker.rs | 129 ++++++++++++++ src/openhuman/credentials/profiles.rs | 163 ++++++++++++++++++ .../tools/impl/browser/browser_open.rs | 156 +++++++++++++++++ .../tools/impl/filesystem/update_memory_md.rs | 111 ++++++++++++ 4 files changed, 559 insertions(+) diff --git a/src/openhuman/cost/tracker.rs b/src/openhuman/cost/tracker.rs index 9a5bd3581c..191642bad1 100644 --- a/src/openhuman/cost/tracker.rs +++ b/src/openhuman/cost/tracker.rs @@ -533,4 +533,133 @@ mod tests { .to_string() .contains("Estimated cost must be a finite, non-negative value")); } + + #[test] + fn invalid_budget_negative_is_rejected() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + assert!(tracker.check_budget(-1.0).is_err()); + } + + #[test] + fn invalid_budget_infinity_is_rejected() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + assert!(tracker.check_budget(f64::INFINITY).is_err()); + } + + #[test] + fn record_usage_when_disabled_is_noop() { + let tmp = TempDir::new().unwrap(); + let config = CostConfig { + enabled: false, + ..Default::default() + }; + let tracker = CostTracker::new(config, tmp.path()).unwrap(); + let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + tracker.record_usage(usage).unwrap(); + let summary = tracker.get_summary().unwrap(); + assert_eq!(summary.request_count, 0); + } + + #[test] + fn record_usage_rejects_negative_cost() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + let mut usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + usage.cost_usd = -1.0; + assert!(tracker.record_usage(usage).is_err()); + } + + #[test] + fn record_usage_rejects_nan_cost() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + let mut usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + usage.cost_usd = f64::NAN; + assert!(tracker.record_usage(usage).is_err()); + } + + #[test] + fn budget_warning_threshold() { + let tmp = TempDir::new().unwrap(); + let config = CostConfig { + enabled: true, + daily_limit_usd: 10.0, + warn_at_percent: 80, + monthly_limit_usd: 1000.0, + ..Default::default() + }; + let tracker = CostTracker::new(config, tmp.path()).unwrap(); + + // Record usage just under warning threshold (80% of 10 = 8.0) + let usage = TokenUsage::new("test/model", 100000, 50000, 1.0, 2.0); + // This has a cost, so let's just check the budget with a projected amount + let check = tracker.check_budget(8.5).unwrap(); + assert!( + matches!(check, BudgetCheck::Warning { .. }), + "expected warning, got {check:?}" + ); + } + + #[test] + fn budget_monthly_exceeded() { + let tmp = TempDir::new().unwrap(); + let config = CostConfig { + enabled: true, + daily_limit_usd: 1000.0, + monthly_limit_usd: 0.01, + ..Default::default() + }; + let tracker = CostTracker::new(config, tmp.path()).unwrap(); + + let usage = TokenUsage::new("test/model", 10000, 5000, 1.0, 2.0); + tracker.record_usage(usage).unwrap(); + + let check = tracker.check_budget(0.01).unwrap(); + assert!(matches!( + check, + BudgetCheck::Exceeded { + period: UsagePeriod::Month, + .. + } + )); + } + + #[test] + fn get_daily_cost_for_today() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + tracker.record_usage(usage.clone()).unwrap(); + + let today_cost = tracker.get_daily_cost(Utc::now().date_naive()).unwrap(); + assert!((today_cost - usage.cost_usd).abs() < 0.001); + } + + #[test] + fn get_monthly_cost_for_current_month() { + let tmp = TempDir::new().unwrap(); + let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap(); + let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0); + tracker.record_usage(usage.clone()).unwrap(); + + let now = Utc::now(); + let monthly_cost = tracker.get_monthly_cost(now.year(), now.month()).unwrap(); + assert!((monthly_cost - usage.cost_usd).abs() < 0.001); + } + + #[test] + fn build_session_model_stats_aggregates_correctly() { + let records = vec![ + CostRecord::new("s1", TokenUsage::new("model-a", 100, 50, 1.0, 1.0)), + CostRecord::new("s1", TokenUsage::new("model-a", 200, 100, 1.0, 1.0)), + CostRecord::new("s1", TokenUsage::new("model-b", 300, 150, 1.0, 1.0)), + ]; + let stats = build_session_model_stats(&records); + assert_eq!(stats.len(), 2); + assert_eq!(stats["model-a"].request_count, 2); + assert_eq!(stats["model-a"].total_tokens, 450); + assert_eq!(stats["model-b"].request_count, 1); + } } diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 6e41b4759f..490f6799d5 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -681,4 +681,167 @@ mod tests { let contents = tokio::fs::read_to_string(path).await.unwrap(); assert!(contents.contains("\"schema_version\": 1")); } + + #[test] + fn token_set_not_expiring_when_no_expiry() { + let token_set = TokenSet { + access_token: "token".into(), + refresh_token: None, + id_token: None, + expires_at: None, + token_type: None, + scope: None, + }; + assert!(!token_set.is_expiring_within(Duration::from_secs(3600))); + } + + #[test] + fn auth_profile_new_token() { + let profile = AuthProfile::new_token("anthropic", "default", "sk-abc".into()); + assert_eq!(profile.provider, "anthropic"); + assert_eq!(profile.profile_name, "default"); + assert_eq!(profile.kind, AuthProfileKind::Token); + assert_eq!(profile.token.as_deref(), Some("sk-abc")); + assert!(profile.token_set.is_none()); + } + + #[test] + fn auth_profile_new_oauth() { + let ts = TokenSet { + access_token: "access".into(), + refresh_token: Some("refresh".into()), + id_token: None, + expires_at: None, + token_type: None, + scope: None, + }; + let profile = AuthProfile::new_oauth("openai", "work", ts); + assert_eq!(profile.kind, AuthProfileKind::OAuth); + assert!(profile.token_set.is_some()); + assert!(profile.token.is_none()); + } + + #[test] + fn auth_profiles_data_default() { + let data = AuthProfilesData::default(); + assert_eq!(data.schema_version, CURRENT_SCHEMA_VERSION); + assert!(data.profiles.is_empty()); + assert!(data.active_profiles.is_empty()); + } + + #[test] + fn remove_nonexistent_profile_returns_false() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let result = store.remove_profile("nonexistent:id").unwrap(); + assert!(!result); + } + + #[test] + fn remove_existing_profile_returns_true() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_token("test", "default", "tok".into()); + let id = profile.id.clone(); + store.upsert_profile(profile, true).unwrap(); + + let removed = store.remove_profile(&id).unwrap(); + assert!(removed); + + let data = store.load().unwrap(); + assert!(!data.profiles.contains_key(&id)); + assert!(!data.active_profiles.values().any(|v| v == &id)); + } + + #[test] + fn set_active_profile_errors_for_missing_profile() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let err = store + .set_active_profile("openai", "missing:id") + .unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn set_active_profile_succeeds_for_existing_profile() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_token("openai", "prod", "tok".into()); + let id = profile.id.clone(); + store.upsert_profile(profile, false).unwrap(); + + store.set_active_profile("openai", &id).unwrap(); + let data = store.load().unwrap(); + assert_eq!(data.active_profiles.get("openai"), Some(&id)); + } + + #[test] + fn clear_active_profile() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_token("openai", "prod", "tok".into()); + store.upsert_profile(profile, true).unwrap(); + + store.clear_active_profile("openai").unwrap(); + let data = store.load().unwrap(); + assert!(data.active_profiles.get("openai").is_none()); + } + + #[test] + fn update_profile_modifies_in_place() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_token("openai", "prod", "tok".into()); + let id = profile.id.clone(); + store.upsert_profile(profile, false).unwrap(); + + let updated = store + .update_profile(&id, |p| { + p.metadata.insert("env".into(), "staging".into()); + Ok(()) + }) + .unwrap(); + assert_eq!( + updated.metadata.get("env").map(|s| s.as_str()), + Some("staging") + ); + } + + #[test] + fn update_profile_errors_for_missing_id() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let err = store.update_profile("missing:id", |_| Ok(())).unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn upsert_preserves_created_at_on_update() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_token("openai", "prod", "tok1".into()); + let id = profile.id.clone(); + let created = profile.created_at; + store.upsert_profile(profile, false).unwrap(); + + std::thread::sleep(Duration::from_millis(10)); + let updated = AuthProfile::new_token("openai", "prod", "tok2".into()); + store.upsert_profile(updated, false).unwrap(); + + let data = store.load().unwrap(); + let loaded = data.profiles.get(&id).unwrap(); + assert_eq!(loaded.created_at, created); + } + + #[test] + fn auth_profile_kind_serde_roundtrip() { + let json = serde_json::to_string(&AuthProfileKind::OAuth).unwrap(); + assert_eq!(json, "\"o-auth\""); // kebab-case + let back: AuthProfileKind = serde_json::from_str(&json).unwrap(); + assert_eq!(back, AuthProfileKind::OAuth); + + let json = serde_json::to_string(&AuthProfileKind::Token).unwrap(); + assert_eq!(json, "\"token\""); + } } diff --git a/src/openhuman/tools/impl/browser/browser_open.rs b/src/openhuman/tools/impl/browser/browser_open.rs index 1233cf02a4..7617eed4b3 100644 --- a/src/openhuman/tools/impl/browser/browser_open.rs +++ b/src/openhuman/tools/impl/browser/browser_open.rs @@ -420,6 +420,162 @@ mod tests { assert!(result.output().contains("read-only")); } + #[test] + fn validate_rejects_empty_url() { + let tool = test_tool(vec!["example.com"]); + let err = tool.validate_url("").unwrap_err().to_string(); + assert!(err.contains("empty")); + } + + #[test] + fn validate_rejects_ipv6_host() { + let tool = test_tool(vec!["example.com"]); + let err = tool + .validate_url("https://[::1]:8080/path") + .unwrap_err() + .to_string(); + // Rejected as IPv6 (starts with '[') + assert!( + err.contains("IPv6") + || err.contains("local/private") + || err.contains("allowed_domains"), + "unexpected error: {err}" + ); + } + + #[test] + fn is_private_or_local_host_detects_local_tld() { + assert!(is_private_or_local_host("myhost.local")); + } + + #[test] + fn is_private_or_local_host_detects_subdomain_localhost() { + assert!(is_private_or_local_host("sub.localhost")); + } + + #[test] + fn is_private_or_local_host_detects_loopback_ipv6() { + assert!(is_private_or_local_host("::1")); + } + + #[test] + fn is_private_or_local_host_detects_10_range() { + assert!(is_private_or_local_host("10.0.0.1")); + } + + #[test] + fn is_private_or_local_host_detects_0_prefix() { + assert!(is_private_or_local_host("0.0.0.0")); + } + + #[test] + fn is_private_or_local_host_detects_link_local() { + assert!(is_private_or_local_host("169.254.1.1")); + } + + #[test] + fn is_private_or_local_host_detects_cgnat() { + assert!(is_private_or_local_host("100.64.0.1")); + } + + #[test] + fn is_private_or_local_host_allows_public() { + assert!(!is_private_or_local_host("8.8.8.8")); + assert!(!is_private_or_local_host("example.com")); + } + + #[test] + fn host_matches_allowlist_exact() { + let domains = vec!["example.com".to_string()]; + assert!(host_matches_allowlist("example.com", &domains)); + assert!(!host_matches_allowlist("other.com", &domains)); + } + + #[test] + fn host_matches_allowlist_subdomain() { + let domains = vec!["example.com".to_string()]; + assert!(host_matches_allowlist("sub.example.com", &domains)); + assert!(!host_matches_allowlist("notexample.com", &domains)); + } + + #[test] + fn normalize_domain_strips_port() { + assert_eq!( + normalize_domain("example.com:8080"), + Some("example.com".into()) + ); + } + + #[test] + fn normalize_domain_strips_leading_trailing_dots() { + assert_eq!( + normalize_domain(".example.com."), + Some("example.com".into()) + ); + } + + #[test] + fn normalize_domain_returns_none_for_empty() { + assert_eq!(normalize_domain(""), None); + assert_eq!(normalize_domain(" "), None); + } + + #[test] + fn normalize_domain_strips_http_prefix() { + assert_eq!( + normalize_domain("http://example.com/path"), + Some("example.com".into()) + ); + } + + #[test] + fn extract_host_rejects_empty_host() { + assert!(extract_host("https://").is_err()); + } + + #[test] + fn extract_host_strips_port() { + assert_eq!( + extract_host("https://example.com:443/path").unwrap(), + "example.com" + ); + } + + #[test] + fn extract_host_lowercases() { + assert_eq!(extract_host("https://EXAMPLE.COM").unwrap(), "example.com"); + } + + #[test] + fn extract_host_strips_trailing_dot() { + assert_eq!( + extract_host("https://example.com./path").unwrap(), + "example.com" + ); + } + + #[test] + fn tool_name_and_description() { + let tool = test_tool(vec!["example.com"]); + assert_eq!(tool.name(), "browser_open"); + assert!(!tool.description().is_empty()); + } + + #[test] + fn parameters_schema_requires_url() { + let tool = test_tool(vec!["example.com"]); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("url"))); + } + + #[tokio::test] + async fn execute_rejects_missing_url_param() { + let tool = test_tool(vec!["example.com"]); + let result = tool.execute(json!({})).await; + assert!(result.is_err() || result.unwrap().is_error); + } + #[tokio::test] async fn execute_blocks_when_rate_limited() { let security = Arc::new(SecurityPolicy { diff --git a/src/openhuman/tools/impl/filesystem/update_memory_md.rs b/src/openhuman/tools/impl/filesystem/update_memory_md.rs index c85ccdc5b2..ed30577fa6 100644 --- a/src/openhuman/tools/impl/filesystem/update_memory_md.rs +++ b/src/openhuman/tools/impl/filesystem/update_memory_md.rs @@ -330,6 +330,117 @@ mod tests { assert!(text.contains("brand new"), "content missing: {text}"); } + #[tokio::test] + async fn replace_section_with_empty_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "## Notes\nold stuff\n## End\ndone\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "Notes", + "content": "" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!( + !text.contains("old stuff"), + "old body should be gone: {text}" + ); + assert!(text.contains("## End"), "other section missing: {text}"); + } + + #[tokio::test] + async fn append_to_empty_memory_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "").unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "first line" + })) + .await + .unwrap(); + assert!(!result.is_error, "unexpected error: {}", result.output()); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("first line")); + } + + #[tokio::test] + async fn replace_section_creates_memory_file_if_missing() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "First", + "content": "hello" + })) + .await + .unwrap(); + assert!(!result.is_error, "unexpected error: {}", result.output()); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert!(text.contains("## First")); + assert!(text.contains("hello")); + } + + #[tokio::test] + async fn rejects_unknown_action() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "delete_all", + "content": "x" + })) + .await + .unwrap(); + assert!(result.is_error); + } + + #[tokio::test] + async fn replace_section_missing_section_title_errors() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "content": "x" + })) + .await; + // May return Err or Ok with is_error + match result { + Ok(r) => assert!(r.is_error), + Err(_) => {} // also acceptable + } + } + + #[test] + fn tool_name_and_description() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + assert_eq!(tool.name(), "update_memory_md"); + assert!(!tool.description().is_empty()); + } + + #[test] + fn parameters_schema_has_required_fields() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("file"))); + assert!(required.contains(&json!("action"))); + } + #[tokio::test] async fn rejects_disallowed_file() { let dir = tempfile::tempdir().unwrap(); From 8ef518a9720aeaa011c82246e0c2d647313e0ffc Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 16 Apr 2026 19:05:04 +0530 Subject: [PATCH 3/3] =?UTF-8?q?test(coverage):=20batch=2011=E2=80=9312=20?= =?UTF-8?q?=E2=80=94=20channels=20schemas,=20conversation=20store=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 33 new tests: - channels/controllers/schemas: per-function input validation, param deserialization, helper coverage - memory/conversations/store: thread lifecycle (create, delete, idempotent), multi-thread, empty/nonexistent thread, purge empty store All 4060 tests pass. --- src/openhuman/channels/controllers/schemas.rs | 226 ++++++++++++++++++ src/openhuman/memory/conversations/store.rs | 144 +++++++++++ 2 files changed, 370 insertions(+) diff --git a/src/openhuman/channels/controllers/schemas.rs b/src/openhuman/channels/controllers/schemas.rs index ea342605da..e14b6266bf 100644 --- a/src/openhuman/channels/controllers/schemas.rs +++ b/src/openhuman/channels/controllers/schemas.rs @@ -676,6 +676,7 @@ fn to_json(outcome: RpcOutcome) -> Result #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn schema_handler_parity() { @@ -781,4 +782,229 @@ mod tests { // schema must still exist with outputs. assert!(!s.outputs.is_empty()); } + + #[test] + fn connect_schema_requires_channel_auth_mode() { + let s = schemas("connect"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"authMode")); + } + + #[test] + fn disconnect_schema_requires_channel_auth_mode() { + let s = schemas("disconnect"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"authMode")); + } + + #[test] + fn status_schema_has_optional_channel() { + let s = schemas("status"); + let chan = s.inputs.iter().find(|f| f.name == "channel"); + assert!(chan.is_some_and(|f| !f.required)); + } + + #[test] + fn test_schema_requires_channel_auth_mode_credentials() { + let s = schemas("test"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"authMode")); + assert!(required.contains(&"credentials")); + } + + #[test] + fn list_schema_has_no_inputs() { + let s = schemas("list"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn discord_link_start_schema() { + let s = schemas("discord_link_start"); + assert_eq!(s.namespace, "channels"); + assert_eq!(s.function, "discord_link_start"); + } + + #[test] + fn discord_link_check_requires_link_token() { + let s = schemas("discord_link_check"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"linkToken")); + } + + #[test] + fn discord_list_channels_requires_guild_id() { + let s = schemas("discord_list_channels"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"guildId")); + } + + #[test] + fn discord_check_permissions_requires_guild_and_channel() { + let s = schemas("discord_check_permissions"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"guildId")); + assert!(required.contains(&"channelId")); + } + + #[test] + fn send_reaction_requires_channel_and_reaction() { + let s = schemas("send_reaction"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"reaction")); + } + + #[test] + fn create_thread_requires_channel_and_title() { + let s = schemas("create_thread"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"title")); + } + + #[test] + fn update_thread_requires_channel_thread_id_action() { + let s = schemas("update_thread"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + assert!(required.contains(&"threadId")); + assert!(required.contains(&"action")); + } + + #[test] + fn list_threads_requires_channel() { + let s = schemas("list_threads"); + let required: Vec<&str> = s + .inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect(); + assert!(required.contains(&"channel")); + } + + #[test] + fn telegram_login_start_schema_has_no_inputs() { + let s = schemas("telegram_login_start"); + assert!(s.inputs.is_empty()); + } + + #[test] + fn deserialize_connect_params() { + let params: ConnectParams = serde_json::from_value(json!({ + "channel": "telegram", + "authMode": "bot_token" + })) + .unwrap(); + assert_eq!(params.channel, "telegram"); + assert_eq!(params.auth_mode, "bot_token"); + assert!(params.credentials.is_none()); + } + + #[test] + fn deserialize_disconnect_params() { + let params: DisconnectParams = serde_json::from_value(json!({ + "channel": "discord", + "authMode": "bot_token" + })) + .unwrap(); + assert_eq!(params.channel, "discord"); + } + + #[test] + fn deserialize_status_params_empty() { + let params: StatusParams = serde_json::from_value(json!({})).unwrap(); + assert!(params.channel.is_none()); + } + + #[test] + fn deserialize_status_params_with_channel() { + let params: StatusParams = serde_json::from_value(json!({"channel": "telegram"})).unwrap(); + assert_eq!(params.channel.as_deref(), Some("telegram")); + } + + #[test] + fn deserialize_send_message_params() { + let params: SendMessageParams = serde_json::from_value(json!({ + "channel": "telegram", + "message": {"text": "hello"} + })) + .unwrap(); + assert_eq!(params.channel, "telegram"); + } + + #[test] + fn to_json_helper() { + let outcome = RpcOutcome::single_log(json!({"ok": true}), "log"); + assert!(to_json(outcome).is_ok()); + } + + #[test] + fn required_string_helper() { + let f = required_string("channel", "channel name"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::String)); + } + + #[test] + fn optional_string_helper() { + let f = optional_string("auth_mode", "auth"); + assert!(!f.required); + } + + #[test] + fn json_output_helper() { + let f = json_output("result", "the result"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::Json)); + } } diff --git a/src/openhuman/memory/conversations/store.rs b/src/openhuman/memory/conversations/store.rs index 1bdb00196c..05b1b63eb5 100644 --- a/src/openhuman/memory/conversations/store.rs +++ b/src/openhuman/memory/conversations/store.rs @@ -554,4 +554,148 @@ mod tests { assert_eq!(stats.message_count, 1); assert!(store.list_threads().expect("list threads").is_empty()); } + + #[test] + fn ensure_thread_is_idempotent() { + let (_temp, store) = make_store(); + let req = CreateConversationThread { + id: "t1".to_string(), + title: "Thread".to_string(), + created_at: "2026-04-10T12:00:00Z".to_string(), + }; + store.ensure_thread(req.clone()).unwrap(); + store.ensure_thread(req).unwrap(); + let threads = store.list_threads().unwrap(); + assert_eq!(threads.len(), 1); + } + + #[test] + fn delete_thread_removes_thread_and_messages() { + let (_temp, store) = make_store(); + store + .ensure_thread(CreateConversationThread { + id: "t1".to_string(), + title: "Thread".to_string(), + created_at: "2026-04-10T12:00:00Z".to_string(), + }) + .unwrap(); + store + .append_message( + "t1", + ConversationMessage { + id: "m1".to_string(), + content: "msg".to_string(), + message_type: "text".to_string(), + extra_metadata: json!({}), + sender: "user".to_string(), + created_at: "2026-04-10T12:01:00Z".to_string(), + }, + ) + .unwrap(); + store.delete_thread("t1", "2026-04-10T12:02:00Z").unwrap(); + let threads = store.list_threads().unwrap(); + assert!(threads.is_empty()); + } + + #[test] + fn delete_nonexistent_thread_is_ok() { + let (_temp, store) = make_store(); + // Should not error + store + .delete_thread("nonexistent", "2026-04-10T12:00:00Z") + .unwrap(); + } + + #[test] + fn get_messages_empty_thread() { + let (_temp, store) = make_store(); + store + .ensure_thread(CreateConversationThread { + id: "t1".to_string(), + title: "Empty".to_string(), + created_at: "2026-04-10T12:00:00Z".to_string(), + }) + .unwrap(); + let messages = store.get_messages("t1").unwrap(); + assert!(messages.is_empty()); + } + + #[test] + fn get_messages_nonexistent_thread() { + let (_temp, store) = make_store(); + let messages = store.get_messages("nonexistent").unwrap(); + assert!(messages.is_empty()); + } + + #[test] + fn multiple_threads_and_messages() { + let (_temp, store) = make_store(); + for i in 0..3 { + store + .ensure_thread(CreateConversationThread { + id: format!("t{i}"), + title: format!("Thread {i}"), + created_at: format!("2026-04-10T12:0{i}:00Z"), + }) + .unwrap(); + store + .append_message( + &format!("t{i}"), + ConversationMessage { + id: format!("m{i}"), + content: format!("msg {i}"), + message_type: "text".to_string(), + extra_metadata: json!({}), + sender: "user".to_string(), + created_at: format!("2026-04-10T12:0{i}:30Z"), + }, + ) + .unwrap(); + } + let threads = store.list_threads().unwrap(); + assert_eq!(threads.len(), 3); + } + + #[test] + fn purge_on_empty_store() { + let (_temp, store) = make_store(); + let stats = store.purge_threads().unwrap(); + assert_eq!(stats.thread_count, 0); + assert_eq!(stats.message_count, 0); + } + + #[test] + fn update_message_nonexistent_returns_error() { + let (_temp, store) = make_store(); + store + .ensure_thread(CreateConversationThread { + id: "t1".to_string(), + title: "Thread".to_string(), + created_at: "2026-04-10T12:00:00Z".to_string(), + }) + .unwrap(); + let result = store.update_message( + "t1", + "nonexistent", + ConversationMessagePatch { + extra_metadata: Some(json!({})), + }, + ); + assert!(result.is_err()); + } + + #[test] + fn conversation_store_new() { + let tmp = TempDir::new().unwrap(); + let store = ConversationStore::new(tmp.path().to_path_buf()); + let threads = store.list_threads().unwrap(); + assert!(threads.is_empty()); + } + + #[test] + fn conversation_purge_stats_default() { + let stats = ConversationPurgeStats::default(); + assert_eq!(stats.thread_count, 0); + assert_eq!(stats.message_count, 0); + } }