test(coverage): batch 5–8 — Rust unit tests toward 80% for 20 modules (#607)

* test(coverage): batch 5–8 — Rust unit tests toward 80% for 20 modules (#530)

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.

* test(coverage): batch 9–10 — browser_open, update_memory_md, credentials profiles, cost tracker (#530)

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.

* test(coverage): batch 11–12 — channels schemas, conversation store (#530)

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.
This commit is contained in:
CodeGhost21
2026-04-16 10:13:33 -07:00
committed by GitHub
parent 7f686003a0
commit ea6895f19d
25 changed files with 3199 additions and 0 deletions
+50
View File
@@ -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"
);
}
}
+123
View File
@@ -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<String> = 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");
}
}
+88
View File
@@ -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::<CapabilityCategory>().unwrap(),
CapabilityCategory::LocalAI
);
assert_eq!(
"local ai".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::LocalAI
);
assert_eq!(
"localai".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::LocalAI
);
assert_eq!(
"screen-intelligence".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::ScreenIntelligence
);
assert_eq!(
"screen intelligence".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::ScreenIntelligence
);
}
#[test]
fn category_from_str_is_case_insensitive() {
assert_eq!(
"CONVERSATION".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::Conversation
);
assert_eq!(
" Team ".parse::<CapabilityCategory>().unwrap(),
CapabilityCategory::Team
);
}
#[test]
fn category_from_str_rejects_unknown() {
let err = "bogus".parse::<CapabilityCategory>().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);
}
}
}
+69
View File
@@ -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");
}
}
+115
View File
@@ -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<dyn PostTurnHook>], ctx: TurnContext) {
log::debug!(
+88
View File
@@ -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<Option<String>> — 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());
}
}
+288
View File
@@ -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<String, Value> = serde_json::from_value(json!({"plan": "pro"})).unwrap();
let result = deserialize_params::<PurchasePlanParams>(params);
assert!(result.is_ok());
assert_eq!(result.unwrap().plan, "pro");
}
#[test]
fn deserialize_top_up_params() {
let params: Map<String, Value> =
serde_json::from_value(json!({"amountUsd": 10.0})).unwrap();
let result = deserialize_params::<TopUpParams>(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<String, Value> =
serde_json::from_value(json!({"amountUsd": 5.0, "gateway": "stripe"})).unwrap();
let result = deserialize_params::<TopUpParams>(params);
assert!(result.is_ok());
assert_eq!(result.unwrap().gateway.as_deref(), Some("stripe"));
}
#[test]
fn deserialize_coinbase_charge_params() {
let params: Map<String, Value> =
serde_json::from_value(json!({"plan": "enterprise", "interval": "annual"})).unwrap();
let result = deserialize_params::<CoinbaseChargeParams>(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<String, Value> = serde_json::from_value(json!({})).unwrap();
let result = deserialize_params::<TransactionsParams>(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<String, Value> =
serde_json::from_value(json!({"limit": 10, "offset": 5})).unwrap();
let result = deserialize_params::<TransactionsParams>(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<String, Value> =
serde_json::from_value(json!({"paymentMethodId": "pm_123"})).unwrap();
let result = deserialize_params::<CardParams>(params);
assert!(result.is_ok());
assert_eq!(result.unwrap().payment_method_id, "pm_123");
}
#[test]
fn deserialize_update_card_params() {
let params: Map<String, Value> = serde_json::from_value(
json!({"paymentMethodId": "pm_1", "payload": {"default": true}}),
)
.unwrap();
let result = deserialize_params::<UpdateCardParams>(params);
assert!(result.is_ok());
}
#[test]
fn deserialize_redeem_coupon_params() {
let params: Map<String, Value> = serde_json::from_value(json!({"code": "SAVE50"})).unwrap();
let result = deserialize_params::<RedeemCouponParams>(params);
assert!(result.is_ok());
assert_eq!(result.unwrap().code, "SAVE50");
}
#[test]
fn deserialize_invalid_params_returns_error() {
let params: Map<String, Value> = serde_json::from_value(json!({})).unwrap();
let result = deserialize_params::<PurchasePlanParams>(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);
}
}
}
@@ -676,6 +676,7 @@ fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String>
#[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));
}
}
@@ -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);
}
}
@@ -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);
}
}
+164
View File
@@ -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 {
@@ -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");
}
}
+243
View File
@@ -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());
}
}
+129
View File
@@ -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);
}
}
+163
View File
@@ -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\"");
}
}
+139
View File
@@ -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());
}
}
+51
View File
@@ -51,3 +51,54 @@ fn handle_snapshot(_params: Map<String, Value>) -> ControllerFuture {
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
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());
}
}
+49
View File
@@ -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 {
+144
View File
@@ -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);
}
}
@@ -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);
}
}
}
+155
View File
@@ -241,3 +241,158 @@ fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
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::<Simple>(m);
assert!(result.is_ok());
}
#[test]
fn deserialize_params_invalid() {
let err =
deserialize_params::<super::super::types::InsertTextParams>(Map::new()).unwrap_err();
assert!(err.contains("invalid params"));
}
#[test]
fn deserialize_params_or_default_empty_returns_default() {
let result =
deserialize_params_or_default::<super::super::types::ReadFieldParams>(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::<super::super::types::ReadFieldParams>(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());
}
}
@@ -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 {
@@ -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();
+206
View File
@@ -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);
}
}
+20
View File
@@ -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());
}
}