mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
789 lines
27 KiB
Rust
789 lines
27 KiB
Rust
//! Focused raw integration coverage for memory sync + memory sources.
|
|
//!
|
|
//! Everything here is local: temp workspaces, loopback HTTP, and a fake `gh`
|
|
//! binary. Run with `--test-threads=1` because config and PATH are process
|
|
//! globals.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use axum::extract::Request;
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::any;
|
|
use axum::{Json, Router};
|
|
use serde_json::{json, Value};
|
|
use tempfile::TempDir;
|
|
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::credentials::{
|
|
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
|
|
};
|
|
use openhuman_core::openhuman::memory_sources::readers::SourceReader;
|
|
use openhuman_core::openhuman::memory_sources::{
|
|
add_source, get_source, list_enabled_by_kind, list_sources,
|
|
remove_composio_source_by_connection_id, remove_source, update_source, upsert_composio_source,
|
|
MemorySourceEntry, MemorySourcePatch, SourceKind,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::bus::{
|
|
ComposioConfigChangedSubscriber, ComposioConnectionCreatedSubscriber, ComposioTriggerSubscriber,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::clickup::ClickUpProvider;
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::github::GitHubProvider;
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::gmail::GmailProvider;
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::slack::{
|
|
run_backfill_via_search, SlackProvider,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::{
|
|
ComposioProvider, ProviderContext, SyncReason, TaskFetchFilter,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::{
|
|
all_composio_sync_providers, get_composio_sync_provider, init_default_composio_sync_providers,
|
|
};
|
|
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, value: impl Into<String>) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, value.into()) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set_path(key: &'static str, value: &Path) -> Self {
|
|
Self::set(key, value.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::remove_var(key) };
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(value) => unsafe { std::env::set_var(self.key, value) },
|
|
None => unsafe { std::env::remove_var(self.key) },
|
|
}
|
|
}
|
|
}
|
|
|
|
fn config_in(tmp: &TempDir) -> Config {
|
|
let mut config = Config {
|
|
config_path: tmp.path().join("config.toml"),
|
|
workspace_dir: tmp.path().join("workspace"),
|
|
action_dir: tmp.path().join("workspace"),
|
|
..Config::default()
|
|
};
|
|
config.secrets.encrypt = false;
|
|
config
|
|
}
|
|
|
|
async fn persist_config(config: &Config) {
|
|
std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir");
|
|
config.save().await.expect("save config");
|
|
}
|
|
|
|
fn source(kind: SourceKind, id: &str) -> MemorySourceEntry {
|
|
MemorySourceEntry {
|
|
id: id.to_string(),
|
|
kind,
|
|
label: format!("{id} label"),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: None,
|
|
glob: None,
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: None,
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
}
|
|
}
|
|
|
|
async fn loopback_router(router: Router) -> (String, tokio::task::JoinHandle<()>) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind loopback");
|
|
let addr = listener.local_addr().expect("loopback addr");
|
|
let handle = tokio::spawn(async move {
|
|
axum::serve(listener, router).await.expect("serve loopback");
|
|
});
|
|
(format!("http://{addr}"), handle)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_sources_registry_persists_crud_and_composio_upserts() {
|
|
let _guard = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let _home = EnvGuard::set_path("HOME", tmp.path());
|
|
let _backend = EnvGuard::unset("BACKEND_URL");
|
|
persist_config(&config).await;
|
|
|
|
let mut folder = source(SourceKind::Folder, "src_folder_round15");
|
|
folder.path = Some(tmp.path().join("notes").to_string_lossy().into_owned());
|
|
folder.glob = Some("**/*.md".to_string());
|
|
let added = add_source(folder.clone()).await.expect("add folder");
|
|
assert_eq!(added.id, folder.id);
|
|
|
|
let duplicate = add_source(folder.clone())
|
|
.await
|
|
.expect_err("duplicate id rejected");
|
|
assert!(duplicate.contains("already exists"));
|
|
|
|
let enabled_folders = list_enabled_by_kind(SourceKind::Folder)
|
|
.await
|
|
.expect("enabled folders");
|
|
assert_eq!(enabled_folders.len(), 1);
|
|
|
|
let updated = update_source(
|
|
&folder.id,
|
|
MemorySourcePatch {
|
|
label: Some("Renamed notes".to_string()),
|
|
enabled: Some(false),
|
|
glob: Some("*.txt".to_string()),
|
|
..MemorySourcePatch::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("update folder");
|
|
assert_eq!(updated.label, "Renamed notes");
|
|
assert!(!updated.enabled);
|
|
assert_eq!(updated.glob.as_deref(), Some("*.txt"));
|
|
|
|
let none_enabled = list_enabled_by_kind(SourceKind::Folder)
|
|
.await
|
|
.expect("disabled folder filtered");
|
|
assert!(none_enabled.is_empty());
|
|
|
|
let first = upsert_composio_source("slack", "conn-round15", "Slack workspace")
|
|
.await
|
|
.expect("insert composio source");
|
|
assert_eq!(first.toolkit.as_deref(), Some("slack"));
|
|
let second = upsert_composio_source("slack", "conn-round15", "Slack renamed")
|
|
.await
|
|
.expect("update composio source");
|
|
assert_eq!(second.id, first.id);
|
|
assert_eq!(second.label, "Slack renamed");
|
|
|
|
let fetched = get_source(&first.id)
|
|
.await
|
|
.expect("get source")
|
|
.expect("source exists");
|
|
assert_eq!(fetched.connection_id.as_deref(), Some("conn-round15"));
|
|
|
|
assert!(remove_source(&folder.id).await.expect("remove folder"));
|
|
assert!(!remove_source("missing-source")
|
|
.await
|
|
.expect("remove missing"));
|
|
let all = list_sources().await.expect("list sources");
|
|
assert_eq!(all.len(), 1);
|
|
assert_eq!(all[0].id, first.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_composio_source_by_connection_id_prunes_on_disconnect_and_survives_reconnect() {
|
|
let _guard = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let _home = EnvGuard::set_path("HOME", tmp.path());
|
|
let _backend = EnvGuard::unset("BACKEND_URL");
|
|
persist_config(&config).await;
|
|
|
|
// Two live composio connections plus an unrelated folder source.
|
|
let gmail_old = upsert_composio_source("gmail", "conn-old", "Gmail · conn-old")
|
|
.await
|
|
.expect("insert gmail");
|
|
upsert_composio_source("slack", "conn-slack", "Slack")
|
|
.await
|
|
.expect("insert slack");
|
|
let mut folder = source(SourceKind::Folder, "src_folder_disc");
|
|
folder.path = Some(tmp.path().join("notes").to_string_lossy().into_owned());
|
|
folder.glob = Some("**/*.md".to_string());
|
|
add_source(folder.clone()).await.expect("add folder");
|
|
|
|
// No-match is a no-op (returns 0, removes nothing).
|
|
assert_eq!(
|
|
remove_composio_source_by_connection_id("conn-does-not-exist")
|
|
.await
|
|
.expect("no-match remove"),
|
|
0
|
|
);
|
|
assert_eq!(list_sources().await.expect("list").len(), 3);
|
|
|
|
// Disconnect: prune ONLY the matching composio source, by connection_id.
|
|
assert_eq!(
|
|
remove_composio_source_by_connection_id("conn-old")
|
|
.await
|
|
.expect("prune on disconnect"),
|
|
1
|
|
);
|
|
let after_disconnect = list_sources().await.expect("list after disconnect");
|
|
assert_eq!(after_disconnect.len(), 2);
|
|
assert!(
|
|
after_disconnect.iter().all(|s| s.id != gmail_old.id),
|
|
"old gmail entry must be gone"
|
|
);
|
|
assert!(
|
|
after_disconnect
|
|
.iter()
|
|
.any(|s| s.connection_id.as_deref() == Some("conn-slack")),
|
|
"the other composio connection must be untouched"
|
|
);
|
|
assert!(
|
|
after_disconnect.iter().any(|s| s.id == folder.id),
|
|
"non-composio folder source must be untouched"
|
|
);
|
|
|
|
// Reconnect: backend mints a NEW connection_id for the same Gmail account.
|
|
// upsert inserts a fresh entry; no stale duplicate is left behind.
|
|
let gmail_new = upsert_composio_source("gmail", "conn-new", "Gmail · conn-new")
|
|
.await
|
|
.expect("reconnect gmail");
|
|
assert_ne!(gmail_new.id, gmail_old.id);
|
|
let final_sources = list_sources().await.expect("final list");
|
|
let gmail_entries: Vec<_> = final_sources
|
|
.iter()
|
|
.filter(|s| s.toolkit.as_deref() == Some("gmail"))
|
|
.collect();
|
|
assert_eq!(
|
|
gmail_entries.len(),
|
|
1,
|
|
"exactly one gmail source after reconnect — no orphan"
|
|
);
|
|
assert_eq!(gmail_entries[0].connection_id.as_deref(), Some("conn-new"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rss_reader_lists_reads_and_reports_feed_errors_from_loopback() {
|
|
let _guard = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let rss_xml = r#"<?xml version="1.0"?>
|
|
<rss version="2.0"><channel>
|
|
<item>
|
|
<title>First & useful</title>
|
|
<link>https://example.test/first</link>
|
|
<description><![CDATA[<p>HTML body & details</p>]]></description>
|
|
<pubDate>Fri, 29 May 2026 10:00:00 GMT</pubDate>
|
|
</item>
|
|
<item>
|
|
<title>Second</title>
|
|
<guid>guid-second</guid>
|
|
<description>Plain <encoded> body</description>
|
|
</item>
|
|
</channel></rss>"#;
|
|
let atom_xml = r#"<?xml version="1.0"?>
|
|
<feed><entry>
|
|
<title>Atom item</title>
|
|
<id>urn:round15:atom</id>
|
|
<summary>Atom summary</summary>
|
|
<link href="https://example.test/atom" />
|
|
<updated>2026-05-29T12:00:00Z</updated>
|
|
</entry></feed>"#;
|
|
let router = Router::new().route(
|
|
"/{feed}",
|
|
any(move |req: Request| {
|
|
let rss_xml = rss_xml.to_string();
|
|
let atom_xml = atom_xml.to_string();
|
|
async move {
|
|
match req.uri().path() {
|
|
"/rss" => (
|
|
[(axum::http::header::CONTENT_TYPE, "application/rss+xml")],
|
|
rss_xml,
|
|
)
|
|
.into_response(),
|
|
"/atom" => (
|
|
[(axum::http::header::CONTENT_TYPE, "application/atom+xml")],
|
|
atom_xml,
|
|
)
|
|
.into_response(),
|
|
"/broken" => (axum::http::StatusCode::BAD_GATEWAY, "bad feed").into_response(),
|
|
_ => (axum::http::StatusCode::NOT_FOUND, "missing").into_response(),
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
let (base, server) = loopback_router(router).await;
|
|
|
|
let reader = openhuman_core::openhuman::memory_sources::readers::rss::RssReader;
|
|
let mut entry = source(SourceKind::RssFeed, "rss-round15");
|
|
entry.url = Some(format!("{base}/rss"));
|
|
entry.max_items = Some(1);
|
|
|
|
let items = reader.list_items(&entry, &config).await.expect("list rss");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].title, "First & useful");
|
|
|
|
let content = reader
|
|
.read_item(&entry, "https://example.test/first", &config)
|
|
.await
|
|
.expect("read rss item");
|
|
assert_eq!(content.id, "https://example.test/first");
|
|
assert_eq!(
|
|
content.content_type,
|
|
openhuman_core::openhuman::memory_sources::ContentType::Html
|
|
);
|
|
assert!(content.body.contains("HTML body"));
|
|
|
|
let mut atom = entry.clone();
|
|
atom.url = Some(format!("{base}/atom"));
|
|
let atom_content = reader
|
|
.read_item(&atom, "urn:round15:atom", &config)
|
|
.await
|
|
.expect("read atom item");
|
|
assert_eq!(atom_content.title, "Atom item");
|
|
assert_eq!(
|
|
atom_content.metadata.get("link").and_then(Value::as_str),
|
|
Some("https://example.test/atom")
|
|
);
|
|
|
|
let missing = reader
|
|
.read_item(&atom, "missing", &config)
|
|
.await
|
|
.expect_err("missing atom item");
|
|
assert!(missing.contains("not found"));
|
|
|
|
let mut broken = entry;
|
|
broken.url = Some(format!("{base}/broken"));
|
|
let err = reader
|
|
.list_items(&broken, &config)
|
|
.await
|
|
.expect_err("http status error");
|
|
assert!(err.contains("502"));
|
|
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn github_reader_uses_fake_gh_for_list_and_read_paths() {
|
|
let _guard = env_lock();
|
|
if std::process::Command::new("gh")
|
|
.arg("--version")
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| !s.success())
|
|
.unwrap_or(true)
|
|
{
|
|
eprintln!("skipping: gh CLI not available");
|
|
return;
|
|
}
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let bin = tmp.path().join("bin");
|
|
std::fs::create_dir_all(&bin).expect("bin dir");
|
|
let script = bin.join("gh");
|
|
write_fake_gh(&script);
|
|
write_fake_git(&bin.join("git"));
|
|
let old_path = std::env::var("PATH").unwrap_or_default();
|
|
let _path = EnvGuard::set("PATH", format!("{}:{old_path}", bin.display()));
|
|
|
|
let reader = openhuman_core::openhuman::memory_sources::readers::github::GithubReader;
|
|
let mut entry = source(SourceKind::GithubRepo, "github-round15");
|
|
entry.url = Some("https://github.com/tinyhumansai/openhuman.git".to_string());
|
|
entry.max_commits = Some(30);
|
|
entry.max_issues = Some(30);
|
|
entry.max_prs = Some(30);
|
|
|
|
let items = reader
|
|
.list_items(&entry, &config)
|
|
.await
|
|
.expect("list github activity");
|
|
assert!(items.iter().any(|i| i.id == "commit:abc123"));
|
|
assert!(items.iter().any(|i| i.id == "issue:7"));
|
|
assert!(items.iter().any(|i| i.id == "pr:9"));
|
|
assert!(!items.iter().any(|i| i.id == "issue:99"));
|
|
|
|
let commit = reader
|
|
.read_item(&entry, "commit:abc123", &config)
|
|
.await
|
|
.expect("read commit");
|
|
assert!(commit.body.contains("Add coverage hooks"));
|
|
assert_eq!(
|
|
commit.metadata.get("sha").and_then(Value::as_str),
|
|
Some("abc123")
|
|
);
|
|
|
|
let issue = reader
|
|
.read_item(&entry, "issue:7", &config)
|
|
.await
|
|
.expect("read issue");
|
|
assert!(issue.body.contains("## Description"));
|
|
assert!(issue.body.contains("Needs fixture coverage"));
|
|
assert_eq!(
|
|
issue.metadata.get("state").and_then(Value::as_str),
|
|
Some("open")
|
|
);
|
|
|
|
let pr = reader
|
|
.read_item(&entry, "pr:9", &config)
|
|
.await
|
|
.expect("read pr");
|
|
assert!(pr.body.contains("not merged"));
|
|
assert_eq!(
|
|
pr.metadata.get("merged").and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
|
|
let invalid = reader
|
|
.read_item(&entry, "unknown:1", &config)
|
|
.await
|
|
.expect_err("invalid id rejected");
|
|
assert!(invalid.contains("invalid item id"));
|
|
|
|
let mut bad_url = entry;
|
|
bad_url.url = Some("https://github.com/tinyhumansai/openhuman/tree/main".to_string());
|
|
let bad = reader
|
|
.list_items(&bad_url, &config)
|
|
.await
|
|
.expect_err("deep link rejected");
|
|
assert!(bad.contains("expected https://github.com/<owner>/<repo>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn composio_providers_fetch_profiles_tasks_and_cover_error_branches() {
|
|
let _guard = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let mut config = config_in(&tmp);
|
|
let requests: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let router = {
|
|
let requests = Arc::clone(&requests);
|
|
Router::new().route(
|
|
"/agent-integrations/composio/execute",
|
|
any(move |Json(body): Json<Value>| {
|
|
let requests = Arc::clone(&requests);
|
|
async move {
|
|
requests.lock().unwrap().push(body.clone());
|
|
Json(json!({
|
|
"success": true,
|
|
"data": execute_response_for(&body),
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
};
|
|
let (base, server) = loopback_router(router).await;
|
|
config.api_url = Some(base);
|
|
persist_config(&config).await;
|
|
let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let _home = EnvGuard::set_path("HOME", tmp.path());
|
|
AuthService::from_config(&config)
|
|
.store_provider_token(
|
|
APP_SESSION_PROVIDER,
|
|
DEFAULT_AUTH_PROFILE_NAME,
|
|
"round15-session-token",
|
|
HashMap::new(),
|
|
true,
|
|
)
|
|
.expect("store session token");
|
|
|
|
let ctx = ProviderContext {
|
|
config: Arc::new(config.clone()),
|
|
toolkit: "github".to_string(),
|
|
connection_id: Some("conn-github".to_string()),
|
|
usage: Default::default(),
|
|
max_items: None,
|
|
sync_depth_days: None,
|
|
};
|
|
let github = GitHubProvider::new();
|
|
let github_profile = github
|
|
.fetch_user_profile(&ctx)
|
|
.await
|
|
.expect("github profile");
|
|
assert_eq!(github_profile.username.as_deref(), Some("octo-round15"));
|
|
assert_eq!(
|
|
github_profile.display_name.as_deref(),
|
|
Some("Round Fifteen")
|
|
);
|
|
|
|
let tasks = github
|
|
.fetch_tasks(
|
|
&ctx,
|
|
&TaskFetchFilter {
|
|
repo: Some("tinyhumansai/openhuman".to_string()),
|
|
labels: vec!["coverage".to_string()],
|
|
state: Some("open".to_string()),
|
|
max: 2,
|
|
..TaskFetchFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("github tasks");
|
|
assert_eq!(tasks.len(), 1);
|
|
assert_eq!(tasks[0].provider, "github");
|
|
assert_eq!(tasks[0].labels, vec!["coverage"]);
|
|
|
|
let clickup_ctx = ProviderContext {
|
|
toolkit: "clickup".to_string(),
|
|
connection_id: Some("conn-clickup".to_string()),
|
|
..ctx.clone()
|
|
};
|
|
let clickup = ClickUpProvider::new();
|
|
let clickup_profile = clickup
|
|
.fetch_user_profile(&clickup_ctx)
|
|
.await
|
|
.expect("clickup profile");
|
|
assert_eq!(clickup_profile.username.as_deref(), Some("9988"));
|
|
assert_eq!(clickup_profile.email.as_deref(), Some("click@example.test"));
|
|
|
|
let clickup_tasks = clickup
|
|
.fetch_tasks(
|
|
&clickup_ctx,
|
|
&TaskFetchFilter {
|
|
team_id: Some("team_1".to_string()),
|
|
list_id: Some("list_1".to_string()),
|
|
max: 3,
|
|
..TaskFetchFilter::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("clickup tasks");
|
|
assert_eq!(clickup_tasks.len(), 1);
|
|
assert_eq!(clickup_tasks[0].provider, "clickup");
|
|
assert_eq!(clickup_tasks[0].priority.as_deref(), Some("high"));
|
|
|
|
let gmail = GmailProvider::new();
|
|
let gmail_err = gmail
|
|
.fetch_tasks(&ctx, &TaskFetchFilter::default())
|
|
.await
|
|
.expect_err("gmail has no task surface");
|
|
assert!(gmail_err.contains("no task-fetch surface"));
|
|
|
|
let slack = SlackProvider::new();
|
|
assert_eq!(slack.toolkit_slug(), "slack");
|
|
assert_eq!(slack.sync_interval_secs(), Some(15 * 60));
|
|
assert!(slack.curated_tools().is_some());
|
|
slack
|
|
.on_trigger(&ctx, "message.created", &json!({"event": "ignored"}))
|
|
.await
|
|
.expect("slack trigger path is defensive");
|
|
|
|
let bad_backfill = run_backfill_via_search(&ctx, 0)
|
|
.await
|
|
.expect_err("zero days rejected");
|
|
assert!(!bad_backfill.trim().is_empty());
|
|
|
|
assert!(!requests.lock().unwrap().is_empty());
|
|
server.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn composio_provider_registry_and_bus_subscribers_expose_stable_metadata() {
|
|
init_default_composio_sync_providers();
|
|
assert!(get_composio_sync_provider("slack").is_some());
|
|
assert!(get_composio_sync_provider("github").is_some());
|
|
assert!(get_composio_sync_provider("clickup").is_some());
|
|
assert!(get_composio_sync_provider("missing").is_none());
|
|
assert!(
|
|
all_composio_sync_providers().len() >= 6,
|
|
"default providers should include gmail/notion/slack/clickup/github/linear"
|
|
);
|
|
|
|
let trigger = ComposioTriggerSubscriber::new();
|
|
let connection = ComposioConnectionCreatedSubscriber::new();
|
|
let config_changed = ComposioConfigChangedSubscriber::new();
|
|
assert_eq!(
|
|
openhuman_core::core::event_bus::EventHandler::name(&trigger),
|
|
"composio::trigger"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::core::event_bus::EventHandler::domains(&trigger),
|
|
Some(&["composio"][..])
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::core::event_bus::EventHandler::name(&connection),
|
|
"composio::connection_created"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::core::event_bus::EventHandler::name(&config_changed),
|
|
"composio::config_changed"
|
|
);
|
|
|
|
for reason in [
|
|
SyncReason::ConnectionCreated,
|
|
SyncReason::Periodic,
|
|
SyncReason::Manual,
|
|
] {
|
|
assert!(!reason.as_str().is_empty());
|
|
}
|
|
}
|
|
|
|
fn execute_response_for(body: &Value) -> Value {
|
|
let tool = body.get("tool").and_then(Value::as_str).unwrap_or_default();
|
|
let args = body.get("arguments").cloned().unwrap_or_else(|| json!({}));
|
|
let data = match tool {
|
|
"GITHUB_GET_THE_AUTHENTICATED_USER" => json!({
|
|
"login": "octo-round15",
|
|
"name": "Round Fifteen",
|
|
"email": "octo@example.test",
|
|
"avatar_url": "https://example.test/avatar.png",
|
|
"html_url": "https://github.com/octo-round15"
|
|
}),
|
|
"GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS" => json!({
|
|
"items": [{
|
|
"id": 1701,
|
|
"number": 17,
|
|
"title": "Cover provider task normalization",
|
|
"body": "Add deterministic task fixture coverage",
|
|
"html_url": "https://github.com/tinyhumansai/openhuman/issues/17",
|
|
"state": "open",
|
|
"updated_at": "2026-05-29T12:34:56Z",
|
|
"labels": [{"name": "coverage"}],
|
|
"user": {"login": "octo-round15"}
|
|
}],
|
|
"total_count": 1,
|
|
"arguments_echo": args
|
|
}),
|
|
"CLICKUP_GET_AUTHORIZED_USER" => json!({
|
|
"user": {
|
|
"id": 9988,
|
|
"username": "Click Round",
|
|
"email": "click@example.test",
|
|
"profilePicture": "https://example.test/click.png"
|
|
}
|
|
}),
|
|
"CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES" => json!({
|
|
"teams": [{ "id": "team_1", "name": "Coverage Team" }]
|
|
}),
|
|
"CLICKUP_GET_FILTERED_TEAM_TASKS" => json!({
|
|
"tasks": [{
|
|
"id": "task_1",
|
|
"name": "Exercise ClickUp normalization",
|
|
"description": "Task body",
|
|
"url": "https://app.clickup.com/t/task_1",
|
|
"status": {"status": "in progress"},
|
|
"assignees": [{"username": "Click Round"}],
|
|
"date_updated": "1780046400000",
|
|
"priority": {"priority": "high"},
|
|
"tags": [{"name": "coverage"}]
|
|
}],
|
|
"arguments_echo": args
|
|
}),
|
|
_ => json!({ "tool": tool, "unhandled": true }),
|
|
};
|
|
json!({
|
|
"data": data,
|
|
"successful": true,
|
|
"error": null,
|
|
"costUsd": 0.0
|
|
})
|
|
}
|
|
|
|
fn write_fake_gh(path: &PathBuf) {
|
|
let script = r#"#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
if [[ "${1:-}" == "--version" ]]; then
|
|
echo "gh version 2.0.0"
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" != "api" ]]; then
|
|
echo "unsupported gh command" >&2
|
|
exit 2
|
|
fi
|
|
case "${2:-}" in
|
|
repos/tinyhumansai/openhuman/commits\?*)
|
|
cat <<'JSON'
|
|
[{"sha":"abc123","commit":{"message":"Add coverage hooks\n\nMore details","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"}}}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/issues\?*)
|
|
cat <<'JSON'
|
|
[{"number":7,"title":"Memory source reader gap","body":"Needs fixture coverage","state":"open","user":{"login":"ada"},"labels":[{"name":"coverage"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":null},{"number":99,"title":"PR-shaped issue","body":"","state":"open","user":{"login":"bot"},"labels":[],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":{}}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/pulls\?*)
|
|
cat <<'JSON'
|
|
[{"number":9,"title":"Raw coverage PR","body":"PR body","state":"open","user":{"login":"grace"},"labels":[{"name":"tests"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T12:00:00Z","merged_at":null,"comments":1}]
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/commits/abc123)
|
|
cat <<'JSON'
|
|
{"sha":"abc123","commit":{"message":"Add coverage hooks\n\nMore details","author":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"},"committer":{"name":"Ada","email":"ada@example.test","date":"2026-05-28T10:00:00Z"}}}
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/issues/7)
|
|
cat <<'JSON'
|
|
{"number":7,"title":"Memory source reader gap","body":"Needs fixture coverage","state":"open","user":{"login":"ada"},"labels":[{"name":"coverage"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T11:00:00Z","pull_request":null}
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/pulls/9)
|
|
cat <<'JSON'
|
|
{"number":9,"title":"Raw coverage PR","body":"PR body","state":"open","user":{"login":"grace"},"labels":[{"name":"tests"}],"created_at":"2026-05-27T10:00:00Z","updated_at":"2026-05-28T12:00:00Z","merged_at":null,"comments":1}
|
|
JSON
|
|
;;
|
|
repos/tinyhumansai/openhuman/issues/7/comments\?*|repos/tinyhumansai/openhuman/issues/9/comments\?*)
|
|
cat <<'JSON'
|
|
[{"user":{"login":"reviewer"},"body":"Looks deterministic","created_at":"2026-05-28T13:00:00Z"}]
|
|
JSON
|
|
;;
|
|
*)
|
|
echo "unexpected gh api path: ${2:-}" >&2
|
|
exit 3
|
|
;;
|
|
esac
|
|
"#;
|
|
std::fs::write(path, script).expect("write fake gh");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(path)
|
|
.expect("fake gh metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(path, perms).expect("chmod fake gh");
|
|
}
|
|
}
|
|
|
|
fn write_fake_git(path: &PathBuf) {
|
|
let script = r#"#!/usr/bin/env bash
|
|
# Fake git that fails on clone/fetch so the GitHub reader falls through to the
|
|
# gh CLI API path (which is intercepted by write_fake_gh).
|
|
case "${1:-}" in
|
|
clone|fetch)
|
|
echo "fatal: repository not found" >&2
|
|
exit 128
|
|
;;
|
|
*)
|
|
exec /usr/bin/git "$@"
|
|
;;
|
|
esac
|
|
"#;
|
|
std::fs::write(path, script).expect("write fake git");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(path)
|
|
.expect("fake git metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(path, perms).expect("chmod fake git");
|
|
}
|
|
}
|