Files
openhuman/tests/memory_sources_e2e.rs
T

838 lines
25 KiB
Rust

//! E2E tests for the memory_sources domain.
//!
//! Boots a real axum JSON-RPC server against an isolated workspace and
//! exercises the full user flow: add source → list → list_items →
//! read_item → ingest into memory tree → verify chunks indexed.
//!
//! Run with: `cargo test --test memory_sources_e2e`
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use axum::http::header::AUTHORIZATION;
use serde_json::{json, Value};
use tempfile::tempdir;
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
use openhuman_core::core::jsonrpc::build_core_http_router;
const TEST_RPC_TOKEN: &str = "memory-sources-e2e-token";
static AUTH_INIT: OnceLock<()> = OnceLock::new();
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn ensure_rpc_auth() {
AUTH_INIT.get_or_init(|| {
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
let token_dir = std::env::temp_dir().join("openhuman-memory-sources-e2e-auth");
init_rpc_token(&token_dir).expect("init rpc auth");
});
}
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &Path) -> Self {
let old = std::env::var(key).ok();
unsafe { std::env::set_var(key, path.as_os_str()) };
Self { key, old }
}
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 EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn write_config(dir: &Path) {
std::fs::create_dir_all(dir).expect("mkdir");
let cfg = r#"
default_model = "e2e-mock-model"
default_temperature = 0.7
[secrets]
encrypt = false
[memory_tree]
embedding_strict = false
"#;
std::fs::write(dir.join("config.toml"), cfg).expect("write config");
let user_dir = dir.join("users").join("local");
std::fs::create_dir_all(&user_dir).expect("mkdir user dir");
std::fs::write(user_dir.join("config.toml"), cfg).expect("write user config");
}
async fn serve() -> (String, tokio::task::JoinHandle<Result<(), std::io::Error>>) {
ensure_rpc_auth();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let handle =
tokio::spawn(async move { axum::serve(listener, build_core_http_router(false)).await });
(format!("http://{addr}"), handle)
}
async fn rpc(base: &str, id: i64, method: &str, params: Value) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("client");
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let url = format!("{}/rpc", base.trim_end_matches('/'));
let resp = client
.post(&url)
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
.json(&body)
.send()
.await
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
assert!(
resp.status().is_success(),
"HTTP error {} for {method}",
resp.status(),
);
resp.json::<Value>()
.await
.unwrap_or_else(|e| panic!("json parse for {method}: {e}"))
}
fn ok(v: &Value, ctx: &str) -> Value {
if let Some(err) = v.get("error") {
panic!("{ctx}: JSON-RPC error: {err}");
}
let outer = v
.get("result")
.unwrap_or_else(|| panic!("{ctx}: missing result: {v}"));
// RpcOutcome wraps the payload under an inner "result" key alongside "logs".
if let Some(inner) = outer.get("result") {
inner.clone()
} else {
outer.clone()
}
}
// ── Tests ─────────────────────────────────────────────────────────
#[tokio::test]
async fn memory_sources_crud_and_folder_read_flow() {
let _guard = env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home = EnvVarGuard::set_to_path("HOME", home);
let _ws = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend = EnvVarGuard::unset("BACKEND_URL");
let _vite = EnvVarGuard::unset("VITE_BACKEND_URL");
write_config(&openhuman_home);
// Create a folder with test markdown files.
let notes_dir = home.join("test-notes");
std::fs::create_dir_all(&notes_dir).expect("mkdir notes");
std::fs::write(
notes_dir.join("architecture.md"),
"# System Architecture\n\n\
The platform uses microservices with three core services.\n\n\
## Auth Service\n\
Handles OAuth2 flows and JWT rotation. Contact: alice@platform.io\n\n\
## Data Pipeline\n\
Event-driven pipeline using Kafka. Throughput: ~50k events/sec. \
Owner: bob@platform.io\n\n\
Last reviewed: 2025-12-15 by the platform team.",
)
.expect("write architecture.md");
std::fs::write(
notes_dir.join("runbook.md"),
"# Incident Runbook\n\n\
## P1: Database Connection Pool Exhaustion\n\
1. Check connection count via pg_stat_activity\n\
2. Scale read replicas if > 90% utilization\n\
3. Escalate to alice@platform.io if write-primary affected\n\n\
Last updated: 2025-11-30",
)
.expect("write runbook.md");
let (rpc_base, rpc_join) = serve().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// ── Step 1: list sources (empty initially) ──
let list0 = rpc(&rpc_base, 1, "openhuman.memory_sources_list", json!({})).await;
let list0_result = ok(&list0, "initial list");
let sources = list0_result
.get("sources")
.and_then(Value::as_array)
.expect("sources array");
assert!(sources.is_empty(), "should start with no sources");
// ── Step 2: add a folder source ──
let add = rpc(
&rpc_base,
2,
"openhuman.memory_sources_add",
json!({
"kind": "folder",
"label": "Test Research Notes",
"path": notes_dir.to_string_lossy(),
"glob": "**/*.md",
}),
)
.await;
let add_result = ok(&add, "add folder source");
let source = add_result.get("source").expect("source in add response");
let source_id = source.get("id").and_then(Value::as_str).expect("source id");
assert_eq!(source.get("kind").and_then(Value::as_str), Some("folder"));
assert_eq!(
source.get("label").and_then(Value::as_str),
Some("Test Research Notes")
);
assert_eq!(source.get("enabled"), Some(&json!(true)));
// ── Step 3: list sources (now has 1) ──
let list1 = rpc(&rpc_base, 3, "openhuman.memory_sources_list", json!({})).await;
let list1_result = ok(&list1, "list after add");
let sources = list1_result
.get("sources")
.and_then(Value::as_array)
.expect("sources array");
assert_eq!(sources.len(), 1);
// ── Step 4: get source by id ──
let get = rpc(
&rpc_base,
4,
"openhuman.memory_sources_get",
json!({ "id": source_id }),
)
.await;
let get_result = ok(&get, "get source");
let fetched = get_result.get("source").expect("source");
assert_eq!(fetched.get("id").and_then(Value::as_str), Some(source_id));
// ── Step 5: list items from the folder source ──
let items_resp = rpc(
&rpc_base,
5,
"openhuman.memory_sources_list_items",
json!({ "source_id": source_id }),
)
.await;
let items_result = ok(&items_resp, "list_items");
let items = items_result
.get("items")
.and_then(Value::as_array)
.expect("items array");
assert_eq!(items.len(), 2, "should list 2 markdown files");
let item_ids: Vec<&str> = items
.iter()
.filter_map(|i| i.get("id").and_then(Value::as_str))
.collect();
assert!(item_ids.contains(&"architecture.md"));
assert!(item_ids.contains(&"runbook.md"));
// ── Step 6: read one item's content ──
let read = rpc(
&rpc_base,
6,
"openhuman.memory_sources_read_item",
json!({
"source_id": source_id,
"item_id": "architecture.md",
}),
)
.await;
let read_result = ok(&read, "read_item");
let content = read_result.get("content").expect("content");
let body = content.get("body").and_then(Value::as_str).expect("body");
assert!(body.contains("System Architecture"));
assert!(body.contains("alice@platform.io"));
assert_eq!(
content.get("content_type").and_then(Value::as_str),
Some("markdown")
);
// ── Step 7: ingest the content into the memory tree ──
let ingest = rpc(
&rpc_base,
7,
"openhuman.memory_tree_ingest",
json!({
"source_kind": "document",
"source_id": format!("memory_sources:{source_id}:architecture.md"),
"owner": "user",
"tags": ["memory_sources", "folder"],
"payload": {
"provider": "memory_sources",
"title": "System Architecture",
"body": body,
},
}),
)
.await;
let ingest_result = ok(&ingest, "memory_tree ingest");
let chunks_written = ingest_result
.get("chunks_written")
.and_then(Value::as_u64)
.unwrap_or(0);
assert!(
chunks_written >= 1,
"should ingest at least 1 chunk, got {chunks_written}; full result: {ingest_result}"
);
// ── Step 8: verify chunks exist via list_sources ──
let ls = rpc(
&rpc_base,
8,
"openhuman.memory_tree_list_sources",
json!({}),
)
.await;
let ls_result = ok(&ls, "memory_tree list_sources");
// list_sources returns {sources: [...]} — but some RPCs wrap it differently.
let mem_sources = if let Some(arr) = ls_result.as_array() {
arr.clone()
} else if let Some(arr) = ls_result.get("sources").and_then(Value::as_array) {
arr.clone()
} else {
panic!("expected sources array in: {ls_result}");
};
assert!(
!mem_sources.is_empty(),
"memory tree should have at least one source after ingest"
);
// ── Step 9: update source label ──
let update = rpc(
&rpc_base,
9,
"openhuman.memory_sources_update",
json!({
"id": source_id,
"label": "Renamed Notes",
}),
)
.await;
let update_result = ok(&update, "update source");
assert_eq!(
update_result
.get("source")
.and_then(|s| s.get("label"))
.and_then(Value::as_str),
Some("Renamed Notes")
);
// ── Step 10: disable source ──
let disable = rpc(
&rpc_base,
10,
"openhuman.memory_sources_update",
json!({
"id": source_id,
"enabled": false,
}),
)
.await;
let disable_result = ok(&disable, "disable source");
assert_eq!(
disable_result.get("source").and_then(|s| s.get("enabled")),
Some(&json!(false))
);
// ── Step 11: remove source ──
let remove = rpc(
&rpc_base,
11,
"openhuman.memory_sources_remove",
json!({ "id": source_id }),
)
.await;
let remove_result = ok(&remove, "remove source");
assert_eq!(remove_result.get("removed"), Some(&json!(true)));
// Verify it's gone.
let list_final = rpc(&rpc_base, 12, "openhuman.memory_sources_list", json!({})).await;
let final_sources = ok(&list_final, "final list")
.get("sources")
.and_then(Value::as_array)
.expect("sources")
.len();
assert_eq!(final_sources, 0, "source should be removed");
// ── Step 12: removing again is idempotent ──
let remove_again = rpc(
&rpc_base,
13,
"openhuman.memory_sources_remove",
json!({ "id": source_id }),
)
.await;
let remove_again_result = ok(&remove_again, "remove again");
assert_eq!(remove_again_result.get("removed"), Some(&json!(false)));
rpc_join.abort();
}
#[tokio::test]
async fn memory_sources_validation_rejects_bad_input() {
let _guard = env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home = EnvVarGuard::set_to_path("HOME", home);
let _ws = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend = EnvVarGuard::unset("BACKEND_URL");
let _vite = EnvVarGuard::unset("VITE_BACKEND_URL");
write_config(&openhuman_home);
let (rpc_base, rpc_join) = serve().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Folder source without path → should error.
let bad_add = rpc(
&rpc_base,
20,
"openhuman.memory_sources_add",
json!({
"kind": "folder",
"label": "Missing path",
}),
)
.await;
assert!(
bad_add.get("error").is_some(),
"adding folder without path should fail: {bad_add}"
);
// GitHub source without url → should error.
let bad_gh = rpc(
&rpc_base,
21,
"openhuman.memory_sources_add",
json!({
"kind": "github_repo",
"label": "No URL",
}),
)
.await;
assert!(
bad_gh.get("error").is_some(),
"adding github_repo without url should fail: {bad_gh}"
);
// list_items for nonexistent source → should error.
let bad_items = rpc(
&rpc_base,
22,
"openhuman.memory_sources_list_items",
json!({ "source_id": "nonexistent" }),
)
.await;
assert!(
bad_items.get("error").is_some(),
"list_items for missing source should fail: {bad_items}"
);
rpc_join.abort();
}
/// GitHub source E2E: add a public repo → list_items (commits/issues/PRs)
/// → read one commit and one issue → ingest into memory tree.
///
/// Requires network + `gh` CLI (or unauthenticated GitHub API access).
/// The test targets a small, stable public repo so API responses are
/// predictable. Gated behind `OPENHUMAN_E2E_NETWORK=1` so CI without
/// outbound GitHub access doesn't fail on rate limits or transient
/// network blips. Run locally with:
/// OPENHUMAN_E2E_NETWORK=1 cargo test --test memory_sources_e2e \
/// memory_sources_github_repo_activity_flow
#[tokio::test]
async fn memory_sources_github_repo_activity_flow() {
if std::env::var("OPENHUMAN_E2E_NETWORK").ok().as_deref() != Some("1") {
eprintln!(
"skipping memory_sources_github_repo_activity_flow — set OPENHUMAN_E2E_NETWORK=1 to enable"
);
return;
}
let _guard = env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home = EnvVarGuard::set_to_path("HOME", home);
let _ws = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend = EnvVarGuard::unset("BACKEND_URL");
let _vite = EnvVarGuard::unset("VITE_BACKEND_URL");
write_config(&openhuman_home);
let (rpc_base, rpc_join) = serve().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// ── Step 1: add a GitHub repo source ──
let add = rpc(
&rpc_base,
100,
"openhuman.memory_sources_add",
json!({
"kind": "github_repo",
"label": "kelseyhightower/nocode",
"url": "https://github.com/kelseyhightower/nocode",
}),
)
.await;
let add_result = ok(&add, "add github source");
let source = add_result.get("source").expect("source");
let source_id = source.get("id").and_then(Value::as_str).expect("id");
assert_eq!(
source.get("kind").and_then(Value::as_str),
Some("github_repo")
);
// ── Step 2: list items — should return commits, issues, PRs ──
let items_resp = rpc(
&rpc_base,
101,
"openhuman.memory_sources_list_items",
json!({ "source_id": source_id }),
)
.await;
let items_result = ok(&items_resp, "github list_items");
let items = items_result
.get("items")
.and_then(Value::as_array)
.expect("items array");
assert!(
!items.is_empty(),
"github repo should have at least some activity items"
);
let has_commits = items.iter().any(|i| {
i.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.starts_with("commit:")
});
assert!(has_commits, "should have commit items");
// ── Step 3: read a commit ──
let commit_item = items
.iter()
.find(|i| {
i.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.starts_with("commit:")
})
.expect("at least one commit");
let commit_id = commit_item
.get("id")
.and_then(Value::as_str)
.expect("commit id");
let read_commit = rpc(
&rpc_base,
102,
"openhuman.memory_sources_read_item",
json!({
"source_id": source_id,
"item_id": commit_id,
}),
)
.await;
let commit_content = ok(&read_commit, "read commit");
let content = commit_content.get("content").expect("content");
let body = content.get("body").and_then(Value::as_str).expect("body");
assert!(body.contains("Commit:"), "commit body should have header");
assert!(body.contains("SHA:"), "commit body should have SHA");
assert_eq!(
content.get("content_type").and_then(Value::as_str),
Some("markdown")
);
// ── Step 4: ingest the commit into memory tree ──
let ingest = rpc(
&rpc_base,
103,
"openhuman.memory_tree_ingest",
json!({
"source_kind": "document",
"source_id": format!("github:{source_id}:{commit_id}"),
"owner": "user",
"tags": ["memory_sources", "github", "commit"],
"payload": {
"provider": "github",
"title": commit_item.get("title").and_then(Value::as_str).unwrap_or("commit"),
"body": body,
},
}),
)
.await;
let ingest_result = ok(&ingest, "ingest github commit");
let chunks = ingest_result
.get("chunks_written")
.and_then(Value::as_u64)
.unwrap_or(0);
assert!(
chunks >= 1,
"should ingest at least 1 chunk from commit, got {chunks}"
);
// ── Step 5: read an issue if one exists ──
if let Some(issue_item) = items.iter().find(|i| {
i.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.starts_with("issue:")
}) {
let issue_id = issue_item
.get("id")
.and_then(Value::as_str)
.expect("issue id");
let read_issue = rpc(
&rpc_base,
104,
"openhuman.memory_sources_read_item",
json!({
"source_id": source_id,
"item_id": issue_id,
}),
)
.await;
let issue_content = ok(&read_issue, "read issue");
let icontent = issue_content.get("content").expect("content");
let ibody = icontent.get("body").and_then(Value::as_str).expect("body");
assert!(ibody.contains("Issue #"), "issue body should have header");
assert!(ibody.contains("State:"), "issue body should have state");
}
// ── Cleanup ──
let remove = rpc(
&rpc_base,
105,
"openhuman.memory_sources_remove",
json!({ "id": source_id }),
)
.await;
ok(&remove, "remove github source");
rpc_join.abort();
}
/// Composio source E2E: add a composio source entry → verify it's in
/// the registry → list_items returns the connection as an item →
/// read_item returns a descriptive placeholder → remove.
///
/// Does NOT require an actual Composio connection — tests the registry
/// and reader behavior with synthetic config.
#[tokio::test]
async fn memory_sources_composio_registry_flow() {
let _guard = env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home = EnvVarGuard::set_to_path("HOME", home);
let _ws = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend = EnvVarGuard::unset("BACKEND_URL");
let _vite = EnvVarGuard::unset("VITE_BACKEND_URL");
write_config(&openhuman_home);
let (rpc_base, rpc_join) = serve().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// ── Step 1: add a composio source ──
let add = rpc(
&rpc_base,
200,
"openhuman.memory_sources_add",
json!({
"kind": "composio",
"label": "Gmail · test@example.com",
"toolkit": "gmail",
"connection_id": "cmp_test_123",
}),
)
.await;
let add_result = ok(&add, "add composio source");
let source = add_result.get("source").expect("source");
let source_id = source.get("id").and_then(Value::as_str).expect("id");
assert_eq!(source.get("kind").and_then(Value::as_str), Some("composio"));
assert_eq!(source.get("toolkit").and_then(Value::as_str), Some("gmail"));
assert_eq!(
source.get("connection_id").and_then(Value::as_str),
Some("cmp_test_123")
);
// ── Step 2: verify it shows up in list ──
let list = rpc(&rpc_base, 201, "openhuman.memory_sources_list", json!({})).await;
let list_result = ok(&list, "list with composio");
let sources = list_result
.get("sources")
.and_then(Value::as_array)
.expect("sources");
assert_eq!(sources.len(), 1);
assert_eq!(
sources[0].get("toolkit").and_then(Value::as_str),
Some("gmail")
);
// ── Step 3: list_items returns the connection as an item ──
let items = rpc(
&rpc_base,
202,
"openhuman.memory_sources_list_items",
json!({ "source_id": source_id }),
)
.await;
let items_result = ok(&items, "composio list_items");
let item_list = items_result
.get("items")
.and_then(Value::as_array)
.expect("items");
assert_eq!(item_list.len(), 1);
assert_eq!(
item_list[0].get("id").and_then(Value::as_str),
Some("cmp_test_123")
);
// ── Step 4: read_item returns descriptive content ──
let read = rpc(
&rpc_base,
203,
"openhuman.memory_sources_read_item",
json!({
"source_id": source_id,
"item_id": "cmp_test_123",
}),
)
.await;
let read_result = ok(&read, "composio read_item");
let content = read_result.get("content").expect("content");
let body = content.get("body").and_then(Value::as_str).expect("body");
assert!(
body.contains("gmail"),
"composio read should mention the toolkit"
);
// ── Step 5: add a second composio source (slack) ──
let add2 = rpc(
&rpc_base,
204,
"openhuman.memory_sources_add",
json!({
"kind": "composio",
"label": "Slack · workspace",
"toolkit": "slack",
"connection_id": "cmp_test_456",
}),
)
.await;
let add2_result = ok(&add2, "add slack composio source");
let slack_id = add2_result
.get("source")
.and_then(|s| s.get("id"))
.and_then(Value::as_str)
.expect("slack source id");
// ── Step 6: list should have both ──
let list2 = rpc(&rpc_base, 205, "openhuman.memory_sources_list", json!({})).await;
let sources2 = ok(&list2, "list with both")
.get("sources")
.and_then(Value::as_array)
.expect("sources")
.len();
assert_eq!(sources2, 2);
// ── Step 7: disable gmail, verify it persists ──
let disable = rpc(
&rpc_base,
206,
"openhuman.memory_sources_update",
json!({
"id": source_id,
"enabled": false,
}),
)
.await;
let disabled = ok(&disable, "disable gmail");
assert_eq!(
disabled.get("source").and_then(|s| s.get("enabled")),
Some(&json!(false))
);
// ── Step 8: remove both ──
for (idx, sid) in [source_id, slack_id].iter().enumerate() {
let r = rpc(
&rpc_base,
210 + idx as i64,
"openhuman.memory_sources_remove",
json!({ "id": sid }),
)
.await;
ok(&r, &format!("remove {sid}"));
}
rpc_join.abort();
}