//! Focused Rust E2E coverage for Worker C module ownership. //! //! This suite intentionally stays inside the memory / memory_sync / channels / //! composio / threads slice and drives the real HTTP JSON-RPC router against //! an isolated workspace. It avoids live network calls. use std::path::Path; use std::sync::{Mutex, OnceLock}; use std::time::Duration; use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; use axum::{response::Html, routing::get, Router}; use reqwest::StatusCode; use serde_json::{json, Value}; use tempfile::{tempdir, 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 = "worker-c-modules-e2e-token"; static AUTH_INIT: OnceLock<()> = OnceLock::new(); static ENV_LOCK: OnceLock> = OnceLock::new(); struct EnvVarGuard { key: &'static str, old: Option, } 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 set(key: &'static str, value: &str) -> Self { let old = std::env::var(key).ok(); unsafe { std::env::set_var(key, value) }; 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(value) => unsafe { std::env::set_var(self.key, value) }, None => unsafe { std::env::remove_var(self.key) }, } } } struct Harness { rpc_base: String, _tmp: TempDir, _guards: Vec, join: tokio::task::JoinHandle>, } impl Drop for Harness { fn drop(&mut self) { self.join.abort(); } } 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-worker-c-e2e-auth"); init_rpc_token(&token_dir).expect("init rpc auth token"); }); } fn write_config(openhuman_dir: &Path) { std::fs::create_dir_all(openhuman_dir).expect("create .openhuman"); let cfg = r#"api_url = "http://127.0.0.1:9" default_model = "worker-c-e2e-model" default_temperature = 0.2 [secrets] encrypt = false [local_ai] enabled = false [memory] provider = "none" embedding_provider = "none" embedding_model = "none" embedding_dimensions = 0 [memory_tree] embedding_strict = false "#; std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml"); let _: openhuman_core::openhuman::config::Config = toml::from_str(cfg).expect("test config must match schema"); } async fn setup() -> Harness { ensure_rpc_auth(); let tmp = tempdir().expect("tempdir"); let home = tmp.path(); write_config(&home.join(".openhuman")); let guards = vec![ EnvVarGuard::set_to_path("HOME", home), EnvVarGuard::unset("OPENHUMAN_WORKSPACE"), EnvVarGuard::unset("BACKEND_URL"), EnvVarGuard::unset("VITE_BACKEND_URL"), EnvVarGuard::unset("OPENHUMAN_API_URL"), EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""), ]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind rpc listener"); let addr = listener.local_addr().expect("rpc listener addr"); let router = build_core_http_router(false); let join = tokio::spawn(async move { axum::serve(listener, router).await }); Harness { rpc_base: format!("http://{addr}"), _tmp: tmp, _guards: guards, join, } } 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 url = format!("{}/rpc", base.trim_end_matches('/')); let response = client .post(&url) .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .json(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, })) .send() .await .unwrap_or_else(|err| panic!("POST {url} {method}: {err}")); assert_eq!( response.status(), StatusCode::OK, "HTTP transport should accept {method}" ); response .json::() .await .unwrap_or_else(|err| panic!("json for {method}: {err}")) } fn ok<'a>(value: &'a Value, context: &str) -> &'a Value { if let Some(error) = value.get("error") { panic!("{context}: unexpected JSON-RPC error: {error}"); } value .get("result") .unwrap_or_else(|| panic!("{context}: missing result: {value}")) } fn payload<'a>(value: &'a Value, context: &str) -> &'a Value { let outer = ok(value, context); outer .get("data") .or_else(|| outer.get("result")) .unwrap_or(outer) } fn error_message<'a>(value: &'a Value, context: &str) -> &'a str { value .get("error") .and_then(|error| error.get("message")) .and_then(Value::as_str) .unwrap_or_else(|| panic!("{context}: expected JSON-RPC error with message: {value}")) } fn assert_rpc_completed(value: &Value, context: &str) { assert!( value.get("result").is_some() || value.get("error").is_some(), "{context}: expected JSON-RPC result or error envelope: {value}" ); } fn find_status_entry<'a>(entries: &'a [Value], channel: &str, auth_mode: &str) -> &'a Value { entries .iter() .find(|entry| { entry.get("channel_id").and_then(Value::as_str) == Some(channel) && entry.get("auth_mode").and_then(Value::as_str) == Some(auth_mode) }) .unwrap_or_else(|| panic!("missing status entry for {channel}/{auth_mode}: {entries:?}")) } #[tokio::test] async fn channels_imessage_config_only_connection_reports_status_and_disconnects() { let _lock = env_lock(); let harness = setup().await; let described = rpc( &harness.rpc_base, 1, "openhuman.channels_describe", json!({ "channel": "imessage" }), ) .await; assert_eq!( payload(&described, "channels_describe") .get("id") .and_then(Value::as_str), Some("imessage") ); let baseline = rpc( &harness.rpc_base, 2, "openhuman.channels_status", json!({ "channel": "imessage" }), ) .await; let baseline_entries = payload(&baseline, "channels_status baseline") .as_array() .expect("status entries"); assert_eq!( find_status_entry(baseline_entries, "imessage", "managed_dm") .get("connected") .and_then(Value::as_bool), Some(false) ); let connected = rpc( &harness.rpc_base, 3, "openhuman.channels_connect", json!({ "channel": "imessage", "authMode": "managed_dm", "credentials": { "allowed_contacts": "alice@example.com, +15550100" } }), ) .await; assert_eq!( payload(&connected, "channels_connect imessage") .get("status") .and_then(Value::as_str), Some("connected") ); let after_connect = rpc( &harness.rpc_base, 4, "openhuman.channels_status", json!({ "channel": "imessage" }), ) .await; let connected_entries = payload(&after_connect, "channels_status after connect") .as_array() .expect("status entries"); assert_eq!( find_status_entry(connected_entries, "imessage", "managed_dm") .get("connected") .and_then(Value::as_bool), Some(true), "config-only iMessage connection must be visible through channels_status" ); let disconnected = rpc( &harness.rpc_base, 5, "openhuman.channels_disconnect", json!({ "channel": "imessage", "authMode": "managed_dm", "clearMemory": false }), ) .await; assert_eq!( payload(&disconnected, "channels_disconnect imessage") .get("disconnected") .and_then(Value::as_bool), Some(true) ); let after_disconnect = rpc( &harness.rpc_base, 6, "openhuman.channels_status", json!({ "channel": "imessage" }), ) .await; let disconnected_entries = payload(&after_disconnect, "channels_status after disconnect") .as_array() .expect("status entries"); assert_eq!( find_status_entry(disconnected_entries, "imessage", "managed_dm") .get("connected") .and_then(Value::as_bool), Some(false) ); } #[tokio::test] async fn channels_remaining_controller_paths_validate_without_live_services() { let _lock = env_lock(); let harness = setup().await; for (id, method) in [ (40, "openhuman.channels_test"), (41, "openhuman.channels_telegram_login_check"), (42, "openhuman.channels_discord_link_check"), (43, "openhuman.channels_discord_list_channels"), (44, "openhuman.channels_discord_check_permissions"), (45, "openhuman.channels_send_message"), (46, "openhuman.channels_send_reaction"), (47, "openhuman.channels_create_thread"), (48, "openhuman.channels_update_thread"), (49, "openhuman.channels_list_threads"), ] { let response = rpc(&harness.rpc_base, id, method, json!({})).await; assert!( response.get("error").is_some(), "{method} should reject missing required params: {response}" ); } for (id, method) in [ (50, "openhuman.channels_telegram_login_start"), (51, "openhuman.channels_discord_link_start"), (52, "openhuman.channels_discord_list_guilds"), ] { let response = rpc(&harness.rpc_base, id, method, json!({})).await; assert_rpc_completed(&response, method); } } #[tokio::test] async fn composio_direct_mode_api_key_and_static_catalogs_round_trip() { let _lock = env_lock(); let harness = setup().await; let capabilities = rpc( &harness.rpc_base, 10, "openhuman.composio_list_capabilities", json!({}), ) .await; let capability_rows = payload(&capabilities, "composio_list_capabilities") .get("capabilities") .and_then(Value::as_array) .expect("capabilities array"); assert!( capability_rows.iter().any(|row| { row.get("toolkit").and_then(Value::as_str) == Some("gmail") || row.get("toolkit").and_then(Value::as_str) == Some("github") }), "static capability matrix should expose common toolkits: {capability_rows:?}" ); let agent_ready = rpc( &harness.rpc_base, 11, "openhuman.composio_list_agent_ready_toolkits", json!({}), ) .await; let ready_toolkits = payload(&agent_ready, "composio_list_agent_ready_toolkits") .get("toolkits") .and_then(Value::as_array) .expect("agent-ready toolkits"); assert!( ready_toolkits .iter() .any(|toolkit| toolkit.as_str() == Some("gmail")), "gmail should remain in the agent-ready catalog: {ready_toolkits:?}" ); let mode0 = rpc( &harness.rpc_base, 12, "openhuman.composio_get_mode", json!({}), ) .await; assert_eq!( payload(&mode0, "composio_get_mode initial") .get("api_key_set") .and_then(Value::as_bool), Some(false) ); let set = rpc( &harness.rpc_base, 13, "openhuman.composio_set_api_key", json!({ "api_key": "cmp_worker_c_test_key", "activate_direct": true }), ) .await; assert_eq!( payload(&set, "composio_set_api_key") .get("mode") .and_then(Value::as_str), Some("direct") ); let mode1 = rpc( &harness.rpc_base, 14, "openhuman.composio_get_mode", json!({}), ) .await; assert_eq!( payload(&mode1, "composio_get_mode direct") .get("api_key_set") .and_then(Value::as_bool), Some(true) ); assert_eq!( payload(&mode1, "composio_get_mode direct") .get("mode") .and_then(Value::as_str), Some("direct") ); let toolkits = rpc( &harness.rpc_base, 15, "openhuman.composio_list_toolkits", json!({}), ) .await; assert!( payload(&toolkits, "composio_list_toolkits direct") .get("toolkits") .and_then(Value::as_array) .expect("toolkits array") .is_empty(), "direct mode should not call the backend tenant allowlist" ); let cleared = rpc( &harness.rpc_base, 16, "openhuman.composio_clear_api_key", json!({}), ) .await; assert_eq!( payload(&cleared, "composio_clear_api_key") .get("mode") .and_then(Value::as_str), Some("backend") ); } #[tokio::test] async fn composio_remaining_controller_paths_validate_without_live_services() { let _lock = env_lock(); let harness = setup().await; for (id, method) in [ (60, "openhuman.composio_authorize"), (61, "openhuman.composio_delete_connection"), (62, "openhuman.composio_execute"), (63, "openhuman.composio_list_github_repos"), (64, "openhuman.composio_create_trigger"), (65, "openhuman.composio_get_user_profile"), (66, "openhuman.composio_sync"), (67, "openhuman.composio_get_user_scopes"), (68, "openhuman.composio_set_user_scopes"), (69, "openhuman.composio_list_available_triggers"), (70, "openhuman.composio_enable_trigger"), (71, "openhuman.composio_disable_trigger"), ] { let response = rpc(&harness.rpc_base, id, method, json!({})).await; assert!( response.get("error").is_some(), "{method} should reject missing required params: {response}" ); } for (id, method) in [ (72, "openhuman.composio_list_connections"), (73, "openhuman.composio_list_tools"), (74, "openhuman.composio_list_trigger_history"), (75, "openhuman.composio_refresh_all_identities"), (76, "openhuman.composio_list_triggers"), ] { let response = rpc(&harness.rpc_base, id, method, json!({})).await; assert_rpc_completed(&response, method); } } #[tokio::test] async fn threads_message_lifecycle_is_persisted_and_validated() { let _lock = env_lock(); let harness = setup().await; let upsert = rpc( &harness.rpc_base, 20, "openhuman.threads_upsert", json!({ "id": "worker-c-thread", "title": "Worker C thread", "created_at": "2026-05-29T12:00:00Z", "labels": ["worker-c", "e2e"] }), ) .await; assert_eq!( payload(&upsert, "threads_upsert") .get("id") .and_then(Value::as_str), Some("worker-c-thread") ); let append = rpc( &harness.rpc_base, 21, "openhuman.threads_message_append", json!({ "thread_id": "worker-c-thread", "message": { "id": "worker-c-message", "content": "Persist this Worker C message", "type": "text", "extraMetadata": { "phase": "initial" }, "sender": "user", "createdAt": "2026-05-29T12:00:01Z" } }), ) .await; assert_eq!( payload(&append, "threads_message_append") .get("id") .and_then(Value::as_str), Some("worker-c-message") ); let listed = rpc( &harness.rpc_base, 22, "openhuman.threads_messages_list", json!({ "thread_id": "worker-c-thread" }), ) .await; let messages = payload(&listed, "threads_messages_list") .get("messages") .and_then(Value::as_array) .expect("messages array"); assert_eq!(messages.len(), 1); assert_eq!( messages[0].get("content").and_then(Value::as_str), Some("Persist this Worker C message") ); let updated = rpc( &harness.rpc_base, 23, "openhuman.threads_message_update", json!({ "thread_id": "worker-c-thread", "message_id": "worker-c-message", "extra_metadata": { "phase": "updated", "verified": true } }), ) .await; assert_eq!( payload(&updated, "threads_message_update").pointer("/extraMetadata/verified"), Some(&json!(true)) ); let missing_list = rpc( &harness.rpc_base, 24, "openhuman.threads_messages_list", json!({ "thread_id": "missing-thread" }), ) .await; assert_eq!( payload(&missing_list, "threads_messages_list missing") .get("count") .and_then(Value::as_u64), Some(0), "listing a missing thread is a read-only empty result" ); let missing_append = rpc( &harness.rpc_base, 25, "openhuman.threads_message_append", json!({ "thread_id": "missing-thread", "message": { "id": "missing-message", "content": "This should not persist", "type": "text", "extraMetadata": {}, "sender": "user", "createdAt": "2026-05-29T12:00:02Z" } }), ) .await; assert!( error_message(&missing_append, "threads_message_append missing").contains("not found"), "mutating a missing thread should return a structured JSON-RPC error: {missing_append}" ); } #[tokio::test] async fn threads_remaining_controller_paths_round_trip() { let _lock = env_lock(); let harness = setup().await; let created = rpc( &harness.rpc_base, 80, "openhuman.threads_create_new", json!({ "labels": ["worker-c"] }), ) .await; let created_thread_id = payload(&created, "threads_create_new") .get("id") .and_then(Value::as_str) .expect("created thread id") .to_string(); let titled = rpc( &harness.rpc_base, 81, "openhuman.threads_update_title", json!({ "thread_id": created_thread_id, "title": "Worker C titled thread" }), ) .await; assert_eq!( payload(&titled, "threads_update_title") .get("title") .and_then(Value::as_str), Some("Worker C titled thread") ); let labeled = rpc( &harness.rpc_base, 82, "openhuman.threads_update_labels", json!({ "thread_id": created_thread_id, "labels": ["worker-c", "remaining"] }), ) .await; assert_eq!( payload(&labeled, "threads_update_labels") .get("labels") .and_then(Value::as_array) .map(Vec::len), Some(2) ); let generated = rpc( &harness.rpc_base, 83, "openhuman.threads_generate_title", json!({ "thread_id": created_thread_id }), ) .await; assert_rpc_completed(&generated, "threads_generate_title"); let turn_state = rpc( &harness.rpc_base, 84, "openhuman.threads_turn_state_get", json!({ "thread_id": created_thread_id }), ) .await; assert_eq!( payload(&turn_state, "threads_turn_state_get").get("turnState"), None, "fresh thread should not have a live turn-state snapshot" ); let turn_states = rpc( &harness.rpc_base, 85, "openhuman.threads_turn_state_list", json!({}), ) .await; assert!(payload(&turn_states, "threads_turn_state_list") .get("turnStates") .and_then(Value::as_array) .is_some()); let clear = rpc( &harness.rpc_base, 86, "openhuman.threads_turn_state_clear", json!({ "thread_id": created_thread_id }), ) .await; assert_eq!( payload(&clear, "threads_turn_state_clear") .get("cleared") .and_then(Value::as_bool), Some(false) ); let board_put = rpc( &harness.rpc_base, 87, "openhuman.threads_task_board_put", json!({ "thread_id": created_thread_id, "cards": [{ "id": "card-1", "title": "Verify remaining controller paths", "status": "todo", "order": 1 }] }), ) .await; assert_eq!( payload(&board_put, "threads_task_board_put") .pointer("/taskBoard/cards/0/id") .and_then(Value::as_str), Some("card-1") ); let board_get = rpc( &harness.rpc_base, 88, "openhuman.threads_task_board_get", json!({ "thread_id": created_thread_id }), ) .await; assert_eq!( payload(&board_get, "threads_task_board_get") .pointer("/taskBoard/cards/0/title") .and_then(Value::as_str), Some("Verify remaining controller paths") ); } #[tokio::test] async fn embeddings_controller_paths_validate_without_live_services() { let _lock = env_lock(); let harness = setup().await; let updated = rpc( &harness.rpc_base, 90, "openhuman.embeddings_update_settings", json!({ "provider": "none", "model": "none", "dimensions": 0, "confirm_wipe": true }), ) .await; assert_rpc_completed(&updated, "embeddings_update_settings"); for (id, method) in [ (91, "openhuman.embeddings_set_api_key"), (92, "openhuman.embeddings_clear_api_key"), (93, "openhuman.embeddings_embed"), ] { let response = rpc(&harness.rpc_base, id, method, json!({})).await; assert!( response.get("error").is_some(), "{method} should reject missing required params: {response}" ); } let tested = rpc( &harness.rpc_base, 94, "openhuman.embeddings_test_connection", json!({ "provider": "none", "model": "none", "dimensions": 0 }), ) .await; assert_rpc_completed(&tested, "embeddings_test_connection"); } #[tokio::test] async fn memory_tree_ingest_feeds_memory_sync_status() { let _lock = env_lock(); let harness = setup().await; let ingest = rpc( &harness.rpc_base, 30, "openhuman.memory_tree_ingest", json!({ "source_kind": "chat", "source_id": "slack:worker-c", "owner": "worker-c@example.com", "tags": ["worker-c", "memory-sync"], "payload": { "platform": "slack", "channel_label": "worker-c", "messages": [ { "author": "alice@example.com", "text": "Worker C coverage confirms memory sync status after ingest.", "timestamp": 1780000000000_i64, "source_ref": "slack://worker-c/msg-1" } ] } }), ) .await; let ingest_payload = payload(&ingest, "memory_tree_ingest"); assert_eq!( ingest_payload.get("source_id").and_then(Value::as_str), Some("slack:worker-c") ); assert_eq!( ingest_payload.get("chunks_written").and_then(Value::as_u64), Some(1) ); let statuses = rpc( &harness.rpc_base, 31, "openhuman.memory_sync_status_list", json!({}), ) .await; let rows = payload(&statuses, "memory_sync_status_list") .get("statuses") .and_then(Value::as_array) .expect("statuses array"); let slack = rows .iter() .find(|row| row.get("provider").and_then(Value::as_str) == Some("slack")) .unwrap_or_else(|| panic!("expected slack status row after ingest: {rows:?}")); assert_eq!(slack.get("chunks_synced").and_then(Value::as_u64), Some(1)); assert_eq!( slack.get("chunks_pending").and_then(Value::as_u64), Some(1), "inert embeddings leave the fresh chunk pending until an embed sidecar exists" ); } #[tokio::test] async fn memory_memory_tree_and_sources_controller_surfaces_are_reachable() { let _lock = env_lock(); let harness = setup().await; let methods = [ "openhuman.memory_init", "openhuman.memory_sync_all", "openhuman.memory_sync_channel", "openhuman.memory_ingestion_status", "openhuman.memory_list_files", "openhuman.memory_read_file", "openhuman.memory_write_file", "openhuman.memory_list_namespaces", "openhuman.memory_query_namespace", "openhuman.memory_clear_namespace", "openhuman.memory_recall_memories", "openhuman.memory_recall_context", "openhuman.memory_context_query", "openhuman.memory_context_recall", "openhuman.memory_doc_put", "openhuman.memory_doc_ingest", "openhuman.memory_doc_list", "openhuman.memory_doc_delete", "openhuman.memory_list_documents", "openhuman.memory_delete_document", "openhuman.memory_namespace_list", "openhuman.memory_kv_set", "openhuman.memory_kv_get", "openhuman.memory_kv_delete", "openhuman.memory_kv_list_namespace", "openhuman.memory_graph_upsert", "openhuman.memory_graph_query", "openhuman.memory_tool_rule_put", "openhuman.memory_tool_rule_get", "openhuman.memory_tool_rule_delete", "openhuman.memory_tool_rule_list", "openhuman.memory_tool_rules_for_prompt", "openhuman.memory_tool_rules_json", "openhuman.memory_learn_all", "openhuman.memory_tree_pipeline_status", "openhuman.memory_tree_ingest", "openhuman.memory_tree_search", "openhuman.memory_tree_recall", "openhuman.memory_tree_list_sources", "openhuman.memory_tree_list_chunks", "openhuman.memory_tree_get_chunk", "openhuman.memory_tree_delete_chunk", "openhuman.memory_tree_top_entities", "openhuman.memory_tree_chunks_for_entity", "openhuman.memory_tree_graph_export", "openhuman.memory_tree_entity_index_for", "openhuman.memory_tree_memory_backfill_status", "openhuman.memory_tree_obsidian_vault_status", "openhuman.memory_tree_flush_now", "openhuman.memory_tree_reset_tree", "openhuman.memory_tree_wipe_all", "openhuman.memory_tree_set_enabled", "openhuman.memory_tree_query_source", "openhuman.memory_tree_search_entities", "openhuman.memory_tree_drill_down", "openhuman.memory_tree_fetch_leaves", "openhuman.memory_tree_chunk_score", "openhuman.memory_sources_list", "openhuman.memory_sources_add", "openhuman.memory_sources_get", "openhuman.memory_sources_update", "openhuman.memory_sources_remove", "openhuman.memory_sources_sync", "openhuman.memory_sources_status_list", "openhuman.memory_sources_list_items", "openhuman.memory_sources_read_item", ]; for (offset, method) in methods.into_iter().enumerate() { let response = rpc(&harness.rpc_base, 100 + offset as i64, method, json!({})).await; assert_rpc_completed(&response, method); } } async fn serve_source_fixtures() -> (String, tokio::task::JoinHandle>) { async fn page() -> Html<&'static str> { Html( r#" Worker C page

Selected coverage article

Web page reader extracts only the requested article body.

"#, ) } async fn feed() -> impl axum::response::IntoResponse { ( [(CONTENT_TYPE, "application/rss+xml; charset=utf-8")], r#" Worker C Feed RSS first item rss-worker-c-1 https://example.test/rss/1 RSS body & decoded entity for coverage. Fri, 29 May 2026 12:00:00 GMT RSS second item rss-worker-c-2 HTML-like RSS content

]]>
"#, ) } let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind fixture listener"); let addr = listener.local_addr().expect("fixture listener addr"); let app = Router::new() .route("/page", get(page)) .route("/feed", get(feed)); let join = tokio::spawn(async move { axum::serve(listener, app).await }); (format!("http://{addr}"), join) } #[tokio::test] async fn memory_sources_folder_web_and_rss_readers_sync_through_rpc() { let _lock = env_lock(); let harness = setup().await; let (fixture_base, fixture_join) = serve_source_fixtures().await; let notes_dir = harness._tmp.path().join("source-notes"); std::fs::create_dir_all(notes_dir.join("nested")).expect("mkdir source notes"); std::fs::write( notes_dir.join("overview.md"), "# Overview\nFolder reader markdown body.", ) .expect("write markdown note"); std::fs::write( notes_dir.join("nested").join("brief.html"), "

Folder reader html body.

", ) .expect("write html note"); std::fs::write( harness._tmp.path().join("outside-secret.md"), "path traversal must not read this", ) .expect("write outside note"); let folder = rpc( &harness.rpc_base, 300, "openhuman.memory_sources_add", json!({ "kind": "folder", "label": "Worker C folder", "path": notes_dir.to_string_lossy(), "glob": "**/*.*" }), ) .await; let folder_id = payload(&folder, "memory_sources_add folder") .pointer("/source/id") .and_then(Value::as_str) .expect("folder source id") .to_string(); let folder_items = rpc( &harness.rpc_base, 301, "openhuman.memory_sources_list_items", json!({ "source_id": folder_id }), ) .await; let folder_item_ids: Vec<&str> = payload(&folder_items, "memory_sources_list_items folder") .get("items") .and_then(Value::as_array) .expect("folder items") .iter() .filter_map(|item| item.get("id").and_then(Value::as_str)) .collect(); assert!(folder_item_ids.contains(&"overview.md")); assert!(folder_item_ids.contains(&"nested/brief.html")); let html_read = rpc( &harness.rpc_base, 302, "openhuman.memory_sources_read_item", json!({ "source_id": folder_id, "item_id": "nested/brief.html" }), ) .await; let html_content = payload(&html_read, "memory_sources_read_item folder html") .get("content") .expect("folder html content"); assert_eq!( html_content.get("content_type").and_then(Value::as_str), Some("html") ); assert!(html_content .get("body") .and_then(Value::as_str) .unwrap_or_default() .contains("Folder reader html body")); let traversal = rpc( &harness.rpc_base, 303, "openhuman.memory_sources_read_item", json!({ "source_id": folder_id, "item_id": "../outside-secret.md" }), ) .await; assert!( error_message(&traversal, "memory_sources_read_item traversal").contains("denied"), "folder reader should reject traversal outside source root: {traversal}" ); let web = rpc( &harness.rpc_base, 304, "openhuman.memory_sources_add", json!({ "kind": "web_page", "label": "Worker C page", "url": format!("{fixture_base}/page"), "selector": "article" }), ) .await; let web_id = payload(&web, "memory_sources_add web") .pointer("/source/id") .and_then(Value::as_str) .expect("web source id") .to_string(); let web_items = rpc( &harness.rpc_base, 305, "openhuman.memory_sources_list_items", json!({ "source_id": web_id }), ) .await; let web_item_id = payload(&web_items, "memory_sources_list_items web") .pointer("/items/0/id") .and_then(Value::as_str) .expect("web item id") .to_string(); assert_eq!(web_item_id, format!("{fixture_base}/page")); let web_read = rpc( &harness.rpc_base, 306, "openhuman.memory_sources_read_item", json!({ "source_id": web_id, "item_id": web_item_id }), ) .await; let web_content = payload(&web_read, "memory_sources_read_item web") .get("content") .expect("web content"); assert_eq!( web_content.get("title").and_then(Value::as_str), Some("Worker C page") ); let web_body = web_content .get("body") .and_then(Value::as_str) .expect("web body"); assert!(web_body.contains("Selected coverage article")); assert!( !web_body.contains("Navigation text"), "selector extraction should not include nav text: {web_body}" ); let rss = rpc( &harness.rpc_base, 307, "openhuman.memory_sources_add", json!({ "kind": "rss_feed", "label": "Worker C feed", "url": format!("{fixture_base}/feed"), "max_items": 1 }), ) .await; let rss_id = payload(&rss, "memory_sources_add rss") .pointer("/source/id") .and_then(Value::as_str) .expect("rss source id") .to_string(); let rss_items = rpc( &harness.rpc_base, 308, "openhuman.memory_sources_list_items", json!({ "source_id": rss_id }), ) .await; let rss_items_payload = payload(&rss_items, "memory_sources_list_items rss") .get("items") .and_then(Value::as_array) .expect("rss items"); assert_eq!( rss_items_payload.len(), 1, "max_items should limit RSS list" ); assert_eq!( rss_items_payload[0].get("id").and_then(Value::as_str), Some("rss-worker-c-1") ); let rss_read = rpc( &harness.rpc_base, 309, "openhuman.memory_sources_read_item", json!({ "source_id": rss_id, "item_id": "rss-worker-c-1" }), ) .await; let rss_content = payload(&rss_read, "memory_sources_read_item rss") .get("content") .expect("rss content"); assert_eq!( rss_content.get("title").and_then(Value::as_str), Some("RSS first item") ); assert!(rss_content .get("body") .and_then(Value::as_str) .unwrap_or_default() .contains("RSS body & decoded entity")); assert_eq!( rss_content .pointer("/metadata/link") .and_then(Value::as_str), Some("https://example.test/rss/1") ); let sync = rpc( &harness.rpc_base, 310, "openhuman.memory_sources_sync", json!({ "source_id": rss_id }), ) .await; assert_eq!( payload(&sync, "memory_sources_sync rss") .get("requested") .and_then(Value::as_bool), Some(true) ); fixture_join.abort(); }