Files
openhuman/tests/memory_sync_providers_raw_coverage_e2e.rs
T

649 lines
23 KiB
Rust

//! Focused raw coverage for Composio memory-sync providers.
//!
//! These tests stay local: temp workspaces plus a loopback backend that
//! returns Composio execute envelopes. Run with `--test-threads=1` because
//! config, HOME, and OPENHUMAN_WORKSPACE are process globals.
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use axum::routing::any;
use axum::{Json, Router};
use serde_json::{json, Value};
use tempfile::TempDir;
use openhuman_core::core::event_bus::{DomainEvent, EventHandler};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::credentials::{
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
};
use openhuman_core::openhuman::memory::global as memory_global;
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::ingest as gmail_ingest;
use openhuman_core::openhuman::memory_sync::composio::providers::slack::ingest as slack_ingest;
use openhuman_core::openhuman::memory_sync::composio::providers::slack::{
SlackMessage, SlackProvider,
};
use openhuman_core::openhuman::memory_sync::composio::providers::{
ComposioProvider, ProviderContext, SyncReason, TaskFetchFilter,
};
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 store_session(config: &Config) {
AuthService::from_config(config)
.store_provider_token(
APP_SESSION_PROVIDER,
DEFAULT_AUTH_PROFILE_NAME,
"round17-session-token",
HashMap::new(),
true,
)
.expect("store app session token");
}
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)
}
fn execute_envelope(data: Value) -> Value {
json!({
"success": true,
"data": {
"data": data,
"successful": true,
"error": null,
"costUsd": 0.0
}
})
}
fn execute_response_for(body: &Value) -> Value {
let tool = body.get("tool").and_then(Value::as_str).unwrap_or("");
let args = body.get("arguments").cloned().unwrap_or_else(|| json!({}));
match tool {
"SLACK_TEST_AUTH" => execute_envelope(json!({
"user_id": "U17A",
"user": "round17",
"team": "Coverage Workspace",
"team_id": "T17",
"url": "https://coverage.slack.com"
})),
"SLACK_RETRIEVE_DETAILED_USER_INFORMATION" => execute_envelope(json!({
"user": {
"real_name": "Round Seventeen",
"profile": {
"email": "round17@example.test",
"image_192": "https://example.test/avatar.png"
}
}
})),
"SLACK_FETCH_TEAM_INFO" => execute_envelope(json!({
"team": {
"email_domain": "example.test",
"icon": { "image_132": "https://example.test/team.png" }
}
})),
"SLACK_LIST_ALL_USERS" => {
let has_cursor = args.get("cursor").is_some();
execute_envelope(json!({
"members": [
{
"id": if has_cursor { "U17B" } else { "U17A" },
"profile": {
"display_name": if has_cursor { "" } else { "Ava" },
"real_name": if has_cursor { "Ben" } else { "" }
},
"name": if has_cursor { "ben" } else { "ava" }
},
{ "id": "", "name": "dropped" }
],
"response_metadata": {
"next_cursor": if has_cursor { "" } else { "page-2" }
}
}))
}
"SLACK_LIST_CONVERSATIONS" => execute_envelope(json!({
"channels": [
{ "id": "C17", "name": "coverage", "is_private": false },
{ "id": "G17", "name": "private-coverage", "is_private": true },
{ "id": "", "name": "dropped" }
],
"response_metadata": { "next_cursor": "" }
})),
"SLACK_FETCH_CONVERSATION_HISTORY" => {
let channel = args.get("channel").and_then(Value::as_str).unwrap_or("");
execute_envelope(json!({
"messages": [
{
"ts": if channel == "G17" { "1714004200.000300" } else { "1714003200.000100" },
"user": "U17A",
"text": if channel == "G17" { "private note for <@U17B>" } else { "shipping coverage with <@U17B>" },
"thread_ts": "1714003200.000100",
"permalink": "https://coverage.slack.com/archives/C17/p1714003200000100"
},
{ "ts": "1714003300.000200", "user": "U17B", "text": " " }
],
"response_metadata": { "next_cursor": "" }
}))
}
"SLACK_SEARCH_MESSAGES" => execute_envelope(json!({
"messages": {
"matches": [
{
"ts": "1714005200.000400",
"user": "U17B",
"text": "search backfill hit for <@U17A>",
"channel": { "id": "C17" },
"permalink": "https://coverage.slack.com/archives/C17/p1714005200000400"
},
{
"ts": "1714005300.000500",
"user": "U17B",
"text": "orphan match stays out",
"channel": { "name": "missing-id" }
}
],
"paging": { "pages": 1 }
}
})),
"GITHUB_GET_THE_AUTHENTICATED_USER" => execute_envelope(json!({
"login": "octo-round17",
"name": "Octo Coverage",
"email": "octo@example.test",
"avatar_url": "https://example.test/octo.png",
"html_url": "https://github.com/octo-round17"
})),
"GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS" => execute_envelope(json!({
"items": [
{
"id": 1701,
"title": "Cover GitHub provider",
"body": "Raw provider coverage",
"state": "open",
"labels": [{ "name": "coverage" }],
"assignee": { "login": "octo-round17" },
"updated_at": "2026-05-29T10:00:00Z",
"html_url": "https://github.com/tinyhumansai/openhuman/issues/1701"
},
{
"title": "Missing id is skipped",
"updated_at": "2026-05-29T09:00:00Z"
}
],
"total_count": 2
})),
"CLICKUP_GET_AUTHORIZED_USER" => execute_envelope(json!({
"user": {
"id": 9917,
"username": "click round17",
"email": "click17@example.test",
"profilePicture": "https://example.test/click.png"
}
})),
"CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES" => execute_envelope(json!({
"teams": [
{ "id": "team_17", "name": "Coverage Team" },
{ "name": "missing id" }
]
})),
"CLICKUP_GET_FILTERED_TEAM_TASKS" => execute_envelope(json!({
"tasks": [
{
"id": "task_17",
"name": "Cover ClickUp provider",
"text_content": "Exercise task persistence",
"status": { "status": "to do" },
"assignees": [{ "username": "click round17" }],
"priority": { "priority": "high" },
"date_updated": "1798545600000",
"url": "https://app.clickup.com/t/task_17"
},
{ "name": "missing id skips", "date_updated": "1798545500000" }
]
})),
_ => execute_envelope(json!({ "unknown_tool": tool, "arguments": args })),
}
}
async fn configured_loopback_context(
tmp: &TempDir,
toolkit: &str,
connection_id: &str,
requests: Arc<Mutex<Vec<Value>>>,
) -> (Config, ProviderContext, tokio::task::JoinHandle<()>) {
let mut config = config_in(tmp);
let router = 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(execute_response_for(&body))
}
}),
);
let (base, server) = loopback_router(router).await;
config.api_url = Some(base);
persist_config(&config).await;
store_session(&config);
memory_global::init(config.workspace_dir.clone()).expect("init global memory client");
let ctx = ProviderContext {
config: Arc::new(config.clone()),
toolkit: toolkit.to_string(),
connection_id: Some(connection_id.to_string()),
usage: Default::default(),
};
(config, ctx, server)
}
#[tokio::test]
async fn gmail_ingest_archives_account_messages_and_legacy_participant_buckets() {
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 page = vec![
json!({
"id": "gmail-round17-a",
"from": "Ava <ava@example.test>",
"to": ["Ben <ben@example.test>", "Casey <casey@example.test>"],
"cc": "ignored@example.test",
"subject": "Re: Coverage thread",
"date": "2026-05-29T10:00:00Z",
"markdown": "First useful message body."
}),
json!({
"id": "gmail-round17-b",
"from": "ben@example.test",
"to": "ava@example.test, casey@example.test",
"subject": "Fwd: Coverage thread",
"internalDate": "1780052400000",
"markdown": "Second useful message body."
}),
json!({
"id": "gmail-round17-empty",
"from": "nobody@example.test",
"to": "ava@example.test",
"subject": "No archive body",
"date": "2026-05-29T12:00:00Z",
"markdown": " "
}),
json!({
"from": "missing-id@example.test",
"to": "ava@example.test",
"subject": "No id",
"date": "2026-05-29T13:00:00Z",
"markdown": "missing id skips per-account ingest"
}),
];
let chunks = gmail_ingest::ingest_page_into_memory_tree(
&config,
"owner-round17",
Some("round17@example.test"),
&page,
)
.await
.expect("per-account gmail ingest");
assert!(chunks >= 2, "expected useful account messages to chunk");
let raw_root = config.memory_tree_content_root().join("raw");
let archived: Vec<_> = walk_files(&raw_root)
.into_iter()
.filter(|p| p.to_string_lossy().contains("gmail-round17-a"))
.collect();
assert_eq!(archived.len(), 1, "raw archive should include message a");
let archived_body = std::fs::read_to_string(&archived[0]).expect("archived body");
assert!(archived_body.contains("**From:** Ava"));
assert!(archived_body.contains("First useful message body."));
let legacy = gmail_ingest::ingest_page_into_memory_tree(
&config,
"owner-round17",
None,
&[
json!({
"id": "legacy-orphan",
"from": "not an address",
"to": [],
"subject": "Fw: ",
"date": "2026-05-29T14:00:00Z",
"markdown": "orphan fallback body"
}),
json!({
"from": "",
"to": [],
"subject": "Skipped",
"date": "2026-05-29T15:00:00Z",
"markdown": "no id and no participants"
}),
],
)
.await
.expect("legacy gmail ingest");
assert!(legacy >= 1, "orphan fallback bucket should ingest");
}
#[tokio::test]
async fn slack_provider_profile_postprocess_trigger_and_ingest_use_loopback_composio() {
let _guard = env_lock();
let tmp = TempDir::new().expect("tempdir");
let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
let _home = EnvGuard::set_path("HOME", tmp.path());
let _backend = EnvGuard::unset("BACKEND_URL");
let requests: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let (config, ctx, server) =
configured_loopback_context(&tmp, "slack", "conn-slack-round17", Arc::clone(&requests))
.await;
let provider = SlackProvider::new();
let profile = provider
.fetch_user_profile(&ctx)
.await
.expect("slack profile");
assert_eq!(profile.username.as_deref(), Some("U17A"));
assert_eq!(profile.email.as_deref(), Some("round17@example.test"));
let mut channels = json!({
"data": {
"channels": [
{ "id": "C17", "name": "coverage", "is_private": false },
{ "id": "", "name": "dropped" }
]
}
});
provider.post_process_action_result("SLACK_LIST_CONVERSATIONS", None, &mut channels);
assert_eq!(channels["channels"].as_array().unwrap().len(), 1);
let mut history = json!({
"data": {
"messages": [
{
"ts": "1714003200.000100",
"user": "U17A",
"text": "shipping coverage with <@U17B>",
"permalink": "https://coverage.slack.com/archives/C17/p1714003200000100"
},
{ "ts": "1714003300.000200", "user": "U17B", "text": " " }
]
}
});
provider.post_process_action_result("SLACK_FETCH_CONVERSATION_HISTORY", None, &mut history);
assert_eq!(history["messages"].as_array().unwrap().len(), 1);
provider
.on_trigger(
&ctx,
"SLACK_CHANNEL_ARCHIVE",
&json!({ "event": "channel" }),
)
.await
.expect("slack non-message trigger");
let messages = vec![
SlackMessage {
channel_id: "C17".to_string(),
channel_name: "coverage".to_string(),
is_private: false,
author: "Ava".to_string(),
author_id: "U17A".to_string(),
text: "Slack raw archive body".to_string(),
timestamp: chrono::DateTime::parse_from_rfc3339("2026-05-29T10:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
ts_raw: "1714003200.000100".to_string(),
thread_ts: Some("1714003200.000100".to_string()),
permalink: Some(
"https://coverage.slack.com/archives/C17/p1714003200000100".to_string(),
),
},
SlackMessage {
channel_id: "G17".to_string(),
channel_name: "private-coverage".to_string(),
is_private: true,
author: String::new(),
author_id: String::new(),
text: " ".to_string(),
timestamp: chrono::DateTime::parse_from_rfc3339("2026-05-29T10:01:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
ts_raw: "1714003260.000200".to_string(),
thread_ts: None,
permalink: None,
},
];
let chunks = slack_ingest::ingest_page_into_memory_tree(
&config,
"owner-round17",
"conn-slack-round17",
&messages,
)
.await
.expect("slack ingest");
assert!(chunks >= 1);
let called_tools: Vec<String> = requests
.lock()
.unwrap()
.iter()
.filter_map(|b| b.get("tool").and_then(Value::as_str).map(str::to_string))
.collect();
assert!(called_tools.contains(&"SLACK_TEST_AUTH".to_string()));
assert!(called_tools.contains(&"SLACK_RETRIEVE_DETAILED_USER_INFORMATION".to_string()));
server.abort();
}
#[tokio::test]
async fn github_clickup_and_composio_bus_cover_provider_branches() {
let _guard = env_lock();
let tmp = TempDir::new().expect("tempdir");
let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path());
let _home = EnvGuard::set_path("HOME", tmp.path());
let _backend = EnvGuard::unset("BACKEND_URL");
let requests: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let (_config, github_ctx, server) =
configured_loopback_context(&tmp, "github", "conn-github-round17", Arc::clone(&requests))
.await;
let github = GitHubProvider::new();
let github_profile = github
.fetch_user_profile(&github_ctx)
.await
.expect("github profile");
assert_eq!(github_profile.username.as_deref(), Some("octo-round17"));
let github_tasks = github
.fetch_tasks(
&github_ctx,
&TaskFetchFilter {
repo: Some("tinyhumansai/openhuman".to_string()),
labels: vec!["coverage".to_string()],
state: Some("open".to_string()),
max: 5,
..TaskFetchFilter::default()
},
)
.await
.expect("github tasks");
assert_eq!(github_tasks.len(), 1);
assert_eq!(github_tasks[0].external_id, "1701");
let github_sync = github
.sync(&github_ctx, SyncReason::ConnectionCreated)
.await
.expect("github sync");
assert_eq!(github_sync.items_ingested, 1);
let click_ctx = ProviderContext {
config: github_ctx.config.clone(),
toolkit: "clickup".to_string(),
connection_id: Some("conn-clickup-round17".to_string()),
usage: Default::default(),
};
let clickup = ClickUpProvider::new();
let click_profile = clickup
.fetch_user_profile(&click_ctx)
.await
.expect("clickup profile");
assert_eq!(click_profile.username.as_deref(), Some("9917"));
let click_tasks = clickup
.fetch_tasks(
&click_ctx,
&TaskFetchFilter {
team_id: Some("team_17".to_string()),
list_id: Some("list_17".to_string()),
max: 5,
..TaskFetchFilter::default()
},
)
.await
.expect("clickup tasks");
assert_eq!(click_tasks.len(), 1);
assert_eq!(click_tasks[0].external_id, "task_17");
let click_sync = clickup
.sync(&click_ctx, SyncReason::Manual)
.await
.expect("clickup sync");
assert_eq!(click_sync.items_ingested, 1);
let trigger_sub = ComposioTriggerSubscriber::new();
assert_eq!(trigger_sub.name(), "composio::trigger");
assert_eq!(trigger_sub.domains().unwrap(), &["composio"]);
trigger_sub
.handle(&DomainEvent::ComposioTriggerReceived {
toolkit: "slack".to_string(),
trigger: "SLACK_MESSAGE_POSTED".to_string(),
metadata_id: "id-round17".to_string(),
metadata_uuid: "uuid-round17".to_string(),
payload: json!({ "text": "hello" }),
})
.await;
let connection_sub = ComposioConnectionCreatedSubscriber::new();
assert_eq!(connection_sub.name(), "composio::connection_created");
connection_sub
.handle(&DomainEvent::ComposioConfigChanged {
mode: "backend".to_string(),
api_key_set: false,
})
.await;
let config_sub = ComposioConfigChangedSubscriber::new();
assert_eq!(config_sub.name(), "composio::config_changed");
config_sub
.handle(&DomainEvent::ComposioConfigChanged {
mode: "direct".to_string(),
api_key_set: true,
})
.await;
let called_tools: Vec<String> = requests
.lock()
.unwrap()
.iter()
.filter_map(|b| b.get("tool").and_then(Value::as_str).map(str::to_string))
.collect();
assert!(called_tools.contains(&"GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS".to_string()));
assert!(called_tools.contains(&"CLICKUP_GET_FILTERED_TEAM_TASKS".to_string()));
server.abort();
}
fn walk_files(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if !root.exists() {
return out;
}
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
let entries = match std::fs::read_dir(&path) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let child = entry.path();
if child.is_dir() {
stack.push(child);
} else {
out.push(child);
}
}
}
out
}