mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user