refactor(rust): move large inline tests to files (#2987)

This commit is contained in:
Steven Enamakel
2026-05-29 15:14:46 -07:00
committed by GitHub
parent 97bf23c53a
commit 525eaf74d7
46 changed files with 9628 additions and 9660 deletions
+2 -335
View File
@@ -1322,338 +1322,5 @@ impl ScannerRegistry {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gateway_guild_create_and_message_create_build_channel_snapshot() {
let mut state = DiscordIngestState::default();
state.apply_gateway_payload(
r#"{
"op":0,
"t":"GUILD_CREATE",
"d":{
"id":"guild-1",
"channels":[{"id":"chan-1","name":"general"}],
"threads":[]
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"hello discord",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice","global_name":"Alice"},
"member":{"nick":"Ali"}
}
}"#,
);
assert_eq!(snapshots.len(), 1);
let snapshot = &snapshots[0];
assert_eq!(snapshot.channel_id, "chan-1");
assert_eq!(snapshot.channel_name, "general");
assert_eq!(snapshot.guild_id.as_deref(), Some("guild-1"));
assert_eq!(snapshot.messages.len(), 1);
assert_eq!(snapshot.messages[0].author, "Ali");
assert_eq!(snapshot.messages[0].body, "hello discord");
assert_eq!(
snapshot.messages[0].source_ref,
"https://discord.com/channels/guild-1/chan-1/msg-1"
);
}
#[test]
fn channel_create_dm_recipients_become_channel_name() {
let mut state = DiscordIngestState::default();
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"CHANNEL_CREATE",
"d":{
"id":"dm-1",
"type":1,
"recipients":[
{"id":"u1","username":"alice"},
{"id":"u2","global_name":"Bob Builder"}
]
}
}"#,
);
assert!(snapshots.is_empty());
let channel = state.channels.get("dm-1").expect("dm channel cached");
assert_eq!(channel.name.as_deref(), Some("alice, Bob Builder"));
}
#[test]
fn channel_update_emits_snapshot_when_messages_are_already_cached() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"hello",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"CHANNEL_UPDATE",
"d":{
"id":"chan-1",
"guild_id":"guild-1",
"name":"renamed-general"
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].channel_name, "renamed-general");
assert_eq!(snapshots[0].messages.len(), 1);
}
#[test]
fn message_update_replaces_existing_message_body() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"after",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].messages.len(), 1);
assert_eq!(snapshots[0].messages[0].body, "after");
}
#[test]
fn message_update_preserves_missing_fields_from_cached_message() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"edited_timestamp":"2026-05-17T12:35:56.000Z"
}
}"#,
);
assert_eq!(snapshots.len(), 1);
let message = &snapshots[0].messages[0];
assert_eq!(message.body, "before");
assert_eq!(message.author, "alice");
assert_eq!(message.author_id, "user-1");
assert_eq!(
message.timestamp_ms,
parse_discord_timestamp_ms("2026-05-17T12:34:56.000Z").unwrap()
);
}
#[test]
fn message_update_with_embed_only_keeps_existing_body_text() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"embeds":[{"title":"preview card"}]
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].messages[0].body, "before");
}
#[test]
fn discord_channel_doc_key_scopes_same_channel_name_by_guild() {
assert_eq!(
discord_channel_doc_key("guild-1", "chan-1"),
"guild-1:chan-1"
);
assert_eq!(
discord_channel_doc_key("guild-2", "chan-1"),
"guild-2:chan-1"
);
assert_eq!(discord_channel_doc_key("", "chan-1"), "@me:chan-1");
}
fn insert_pending_tasks(
registry: &ScannerRegistry,
account_id: &str,
count: usize,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut tasks = Vec::with_capacity(count);
let mut abort_handles = Vec::with_capacity(count);
for _ in 0..count {
let task = tokio::spawn(async {
std::future::pending::<()>().await;
});
abort_handles.push(task.abort_handle());
tasks.push(task);
}
registry
.started
.lock()
.insert(account_id.to_string(), abort_handles);
tasks
}
async fn assert_cancelled(task: tokio::task::JoinHandle<()>) {
let err = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("aborted scanner task should finish")
.expect_err("scanner task should be cancelled");
assert!(err.is_cancelled());
}
async fn assert_all_cancelled(tasks: Vec<tokio::task::JoinHandle<()>>) {
for task in tasks {
assert_cancelled(task).await;
}
}
#[tokio::test]
async fn registry_forget_aborts_all_handles_for_account_only() {
let registry = ScannerRegistry::default();
let account_tasks = insert_pending_tasks(&registry, "acct-1", 2);
let survivor_tasks = insert_pending_tasks(&registry, "acct-2", 1);
registry.forget("acct-1");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-2"));
}
assert_all_cancelled(account_tasks).await;
assert!(
!survivor_tasks[0].is_finished(),
"forget(acct-1) must not abort acct-2"
);
assert_eq!(registry.forget_all(), 1);
assert_all_cancelled(survivor_tasks).await;
}
#[tokio::test]
async fn registry_forget_missing_account_is_noop() {
let registry = ScannerRegistry::default();
let mut tasks = insert_pending_tasks(&registry, "acct-1", 1);
registry.forget("missing");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-1"));
}
assert!(
!tasks[0].is_finished(),
"forget(missing) must not abort existing scanners"
);
registry.forget("acct-1");
assert_cancelled(tasks.pop().expect("task")).await;
}
#[tokio::test]
async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() {
let registry = ScannerRegistry::default();
let task_a = insert_pending_tasks(&registry, "acct-1", 2);
let task_b = insert_pending_tasks(&registry, "acct-2", 3);
assert_eq!(registry.forget_all(), 5);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(task_a).await;
assert_all_cancelled(task_b).await;
}
#[tokio::test]
async fn registry_forget_all_is_repeatable_noop_after_drain() {
let registry = ScannerRegistry::default();
assert_eq!(registry.forget_all(), 0);
let tasks = insert_pending_tasks(&registry, "acct-1", 1);
assert_eq!(registry.forget_all(), 1);
assert_eq!(registry.forget_all(), 0);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(tasks).await;
}
}
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,333 @@
use super::*;
#[test]
fn gateway_guild_create_and_message_create_build_channel_snapshot() {
let mut state = DiscordIngestState::default();
state.apply_gateway_payload(
r#"{
"op":0,
"t":"GUILD_CREATE",
"d":{
"id":"guild-1",
"channels":[{"id":"chan-1","name":"general"}],
"threads":[]
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"hello discord",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice","global_name":"Alice"},
"member":{"nick":"Ali"}
}
}"#,
);
assert_eq!(snapshots.len(), 1);
let snapshot = &snapshots[0];
assert_eq!(snapshot.channel_id, "chan-1");
assert_eq!(snapshot.channel_name, "general");
assert_eq!(snapshot.guild_id.as_deref(), Some("guild-1"));
assert_eq!(snapshot.messages.len(), 1);
assert_eq!(snapshot.messages[0].author, "Ali");
assert_eq!(snapshot.messages[0].body, "hello discord");
assert_eq!(
snapshot.messages[0].source_ref,
"https://discord.com/channels/guild-1/chan-1/msg-1"
);
}
#[test]
fn channel_create_dm_recipients_become_channel_name() {
let mut state = DiscordIngestState::default();
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"CHANNEL_CREATE",
"d":{
"id":"dm-1",
"type":1,
"recipients":[
{"id":"u1","username":"alice"},
{"id":"u2","global_name":"Bob Builder"}
]
}
}"#,
);
assert!(snapshots.is_empty());
let channel = state.channels.get("dm-1").expect("dm channel cached");
assert_eq!(channel.name.as_deref(), Some("alice, Bob Builder"));
}
#[test]
fn channel_update_emits_snapshot_when_messages_are_already_cached() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"hello",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"CHANNEL_UPDATE",
"d":{
"id":"chan-1",
"guild_id":"guild-1",
"name":"renamed-general"
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].channel_name, "renamed-general");
assert_eq!(snapshots[0].messages.len(), 1);
}
#[test]
fn message_update_replaces_existing_message_body() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"after",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].messages.len(), 1);
assert_eq!(snapshots[0].messages[0].body, "after");
}
#[test]
fn message_update_preserves_missing_fields_from_cached_message() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"guild_id":"guild-1",
"edited_timestamp":"2026-05-17T12:35:56.000Z"
}
}"#,
);
assert_eq!(snapshots.len(), 1);
let message = &snapshots[0].messages[0];
assert_eq!(message.body, "before");
assert_eq!(message.author, "alice");
assert_eq!(message.author_id, "user-1");
assert_eq!(
message.timestamp_ms,
parse_discord_timestamp_ms("2026-05-17T12:34:56.000Z").unwrap()
);
}
#[test]
fn message_update_with_embed_only_keeps_existing_body_text() {
let mut state = DiscordIngestState::default();
let _ = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_CREATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"content":"before",
"timestamp":"2026-05-17T12:34:56.000Z",
"author":{"id":"user-1","username":"alice"}
}
}"#,
);
let snapshots = state.apply_gateway_payload(
r#"{
"op":0,
"t":"MESSAGE_UPDATE",
"d":{
"id":"msg-1",
"channel_id":"chan-1",
"embeds":[{"title":"preview card"}]
}
}"#,
);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].messages[0].body, "before");
}
#[test]
fn discord_channel_doc_key_scopes_same_channel_name_by_guild() {
assert_eq!(
discord_channel_doc_key("guild-1", "chan-1"),
"guild-1:chan-1"
);
assert_eq!(
discord_channel_doc_key("guild-2", "chan-1"),
"guild-2:chan-1"
);
assert_eq!(discord_channel_doc_key("", "chan-1"), "@me:chan-1");
}
fn insert_pending_tasks(
registry: &ScannerRegistry,
account_id: &str,
count: usize,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut tasks = Vec::with_capacity(count);
let mut abort_handles = Vec::with_capacity(count);
for _ in 0..count {
let task = tokio::spawn(async {
std::future::pending::<()>().await;
});
abort_handles.push(task.abort_handle());
tasks.push(task);
}
registry
.started
.lock()
.insert(account_id.to_string(), abort_handles);
tasks
}
async fn assert_cancelled(task: tokio::task::JoinHandle<()>) {
let err = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("aborted scanner task should finish")
.expect_err("scanner task should be cancelled");
assert!(err.is_cancelled());
}
async fn assert_all_cancelled(tasks: Vec<tokio::task::JoinHandle<()>>) {
for task in tasks {
assert_cancelled(task).await;
}
}
#[tokio::test]
async fn registry_forget_aborts_all_handles_for_account_only() {
let registry = ScannerRegistry::default();
let account_tasks = insert_pending_tasks(&registry, "acct-1", 2);
let survivor_tasks = insert_pending_tasks(&registry, "acct-2", 1);
registry.forget("acct-1");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-2"));
}
assert_all_cancelled(account_tasks).await;
assert!(
!survivor_tasks[0].is_finished(),
"forget(acct-1) must not abort acct-2"
);
assert_eq!(registry.forget_all(), 1);
assert_all_cancelled(survivor_tasks).await;
}
#[tokio::test]
async fn registry_forget_missing_account_is_noop() {
let registry = ScannerRegistry::default();
let mut tasks = insert_pending_tasks(&registry, "acct-1", 1);
registry.forget("missing");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-1"));
}
assert!(
!tasks[0].is_finished(),
"forget(missing) must not abort existing scanners"
);
registry.forget("acct-1");
assert_cancelled(tasks.pop().expect("task")).await;
}
#[tokio::test]
async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() {
let registry = ScannerRegistry::default();
let task_a = insert_pending_tasks(&registry, "acct-1", 2);
let task_b = insert_pending_tasks(&registry, "acct-2", 3);
assert_eq!(registry.forget_all(), 5);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(task_a).await;
assert_all_cancelled(task_b).await;
}
#[tokio::test]
async fn registry_forget_all_is_repeatable_noop_after_drain() {
let registry = ScannerRegistry::default();
assert_eq!(registry.forget_all(), 0);
let tasks = insert_pending_tasks(&registry, "acct-1", 1);
assert_eq!(registry.forget_all(), 1);
assert_eq!(registry.forget_all(), 0);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(tasks).await;
}
+2 -943
View File
@@ -3726,946 +3726,5 @@ fn macos_os_version() -> Option<String> {
}
#[cfg(test)]
mod tests {
use super::*;
// Tests that read/write process-global env vars must serialize through this
// mutex. Rust's test runner executes tests in parallel by default; without
// coordination, concurrent set_var / remove_var calls race and produce
// spurious failures.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Test that is_daemon_mode correctly detects daemon flag variations
#[test]
fn is_daemon_mode_detects_daemon_flag() {
// Note: This test relies on the current process args, so in test mode
// it will typically return false. We verify the function is callable.
let _result = is_daemon_mode();
}
/// Test core_rpc_url returns expected format
#[test]
fn core_rpc_url_returns_expected_format() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc");
let url = core_rpc_url();
assert_eq!(url, "http://localhost:9999/rpc");
std::env::remove_var("OPENHUMAN_CORE_RPC_URL");
let url = core_rpc_url();
assert_eq!(url, "http://127.0.0.1:7788/rpc");
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
/// Test overlay_parent_rpc_url handles empty env var
#[test]
fn overlay_parent_rpc_url_handles_empty() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "");
assert!(overlay_parent_rpc_url().is_none());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", " ");
assert!(overlay_parent_rpc_url().is_none());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc");
assert_eq!(
overlay_parent_rpc_url(),
Some("http://127.0.0.1:7788/rpc".to_string())
);
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
#[test]
fn reset_local_data_windows_file_lock_error_codes_are_recognized() {
assert!(is_windows_file_lock_raw_os_error(Some(32)));
assert!(is_windows_file_lock_raw_os_error(Some(33)));
assert!(!is_windows_file_lock_raw_os_error(Some(5)));
assert!(!is_windows_file_lock_raw_os_error(None));
}
#[test]
fn reset_local_data_delete_error_keeps_generic_message_for_other_errors() {
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let result = reset_local_data_delete_error(
"current openhuman dir",
std::path::Path::new("/tmp/openhuman"),
&err,
);
let msg = result.expect_err("non-lock errors must still surface to the UI");
assert!(msg.starts_with("Failed to remove current openhuman dir at /tmp/openhuman:"));
assert!(!msg.contains("Close all OpenHuman windows and try again"));
}
#[cfg(windows)]
#[test]
fn reset_local_data_delete_error_swallows_lock_failure_when_path_disappeared() {
// Race condition the reboot fallback now handles: the locked path
// was gone by the time `schedule_path_for_reboot_deletion` ran its
// `symlink_metadata` probe, so the reset goal is already met. The
// helper must return `Ok(())` rather than surfacing a confusing
// "couldn't remove (it's not there)" toast.
let dir = tempfile::tempdir().expect("tempdir for reset error test");
let missing = dir.path().join("definitely-not-there");
let err = std::io::Error::from_raw_os_error(32);
let result = reset_local_data_delete_error("current openhuman dir", &missing, &err);
assert!(
result.is_ok(),
"expected NotFound + empty partial schedule to be swallowed as success, got {result:?}"
);
}
#[cfg(windows)]
#[test]
fn reset_local_data_delete_error_reports_reboot_schedule_counts() {
// When the lock fallback can walk a real directory tree, the user
// message should report how much has been queued so the support
// log preserves "what was actually scheduled". Scheduling itself
// may still fail at the MoveFileExW step in unprivileged test
// processes (the registry key write requires administrator); the
// fallback then carries a partial schedule that the error path
// surfaces, so both branches must keep mentioning the lock cause
// *and* expose either the queued counts or the schedule failure.
let dir = tempfile::tempdir().expect("tempdir for reset error test");
let target = dir.path().join("reset-mock");
std::fs::create_dir_all(target.join("nested")).expect("mkdir nested");
std::fs::write(target.join("a.txt"), b"x").expect("write a.txt");
std::fs::write(target.join("nested").join("b.txt"), b"y").expect("write b.txt");
let err = std::io::Error::from_raw_os_error(32);
let result = reset_local_data_delete_error("current openhuman dir", &target, &err);
// Path exists on disk, so the fallback must surface the outcome —
// either an "all-queued" success-but-needs-reboot message (admin)
// or one of the failure flavours (non-admin).
let msg = result
.expect_err("path exists, fallback must report queued counts or scheduling failure");
let admin_path = msg.contains("queued for deletion the next time you restart Windows")
&& msg.contains("2 files and 2 folders");
let user_full_fail = msg.contains("scheduling deletion on next reboot also failed");
let user_partial = msg.contains("queued for the next reboot before scheduling failed");
assert!(
admin_path || user_full_fail || user_partial,
"expected reboot-scheduled, fully-failed, or partial-fail message, got: {msg}"
);
// Whatever branch we land on, the user must still be told the lock
// is what blocked the immediate removal.
assert!(
msg.contains("locked by another OpenHuman window or process")
|| msg.contains("another process is holding it open"),
"missing lock cause: {msg}"
);
}
/// Tests for setup_tray conditional compilation
/// The PR adds two versions of setup_tray():
/// 1. No-op for linux + cef: logs warning and returns Ok(())
/// 2. Full implementation for other platforms
///
/// These tests verify the function signatures are correct and
/// the compile-time cfg blocks are properly set up.
/// Verify setup_tray function exists and has correct signature
/// This test passes if the code compiles, as the function signature
/// is validated by the compiler.
#[test]
fn setup_tray_function_signature_compiles() {
// This test exists to ensure the conditional compilation
// of setup_tray is valid. The function is not actually called
// here because it requires a full Tauri AppHandle.
// The cfg attributes ensure only one version exists at compile time.
}
/// Test that AppRuntime is defined for the current feature set
#[test]
fn app_runtime_type_exists() {
// This test verifies AppRuntime is properly defined
// based on the cef feature flag.
// The type alias exists at module scope and is used throughout.
fn _check_runtime<R: tauri::Runtime>() {}
// _check_runtime::<AppRuntime>(); // Would require importing
}
#[test]
fn no_app_update_available_result_is_quiet_unavailable() {
let info = no_app_update_available("0.53.43".to_string());
assert_eq!(info.current_version, "0.53.43");
assert!(!info.available);
assert!(info.available_version.is_none());
assert!(info.body.is_none());
}
/// Verify tray logging patterns exist (grep-friendly)
#[test]
fn tray_setup_logging_patterns_exist() {
// These log patterns from the PR are grep-friendly:
// "[tray] skipping tray setup on linux+cef: ..."
// "[tray] setting up tray icon"
// "[tray] tray icon ready"
// "[tray] action=show_window ..."
// "[tray] action=quit ..."
// "[tray] failed to setup tray icon ..."
// "[app] RunEvent::Ready — GTK initialized, setting up tray"
//
// This test passes if the code compiles with these log messages.
}
// -------------------------------------------------------------------------
// macos_os_version (issue #1012)
// -------------------------------------------------------------------------
/// On macOS, sw_vers is always present and must return a version string.
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_returns_some() {
assert!(
macos_os_version().is_some(),
"sw_vers -productVersion must succeed on macOS"
);
}
/// The returned version must be a non-empty trimmed string.
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_is_nonempty() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
assert!(!ver.is_empty());
// No leading/trailing whitespace (the impl trims).
assert_eq!(ver, ver.trim());
}
/// The version string must look like dot-separated integers ("14.5", "13.2.1").
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_is_dotted_integer_format() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
let all_numeric_parts = ver
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
assert!(
all_numeric_parts,
"os version {ver:?} must be dot-separated integers (e.g. '14.5')"
);
}
/// The version must have at least one component (e.g. a bare major "15" is valid).
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_has_at_least_one_component() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
assert!(
!ver.split('.').next().unwrap_or("").is_empty(),
"version must have at least one numeric component"
);
}
// -------------------------------------------------------------------------
// WSL + X11 desktop startup warning (issue #1653)
// -------------------------------------------------------------------------
#[test]
fn wsl_x11_warning_detects_classic_x11_forwarding() {
assert!(should_warn_for_wsl_x11_desktop(true, true, false, false));
}
#[test]
fn wsl_x11_warning_skips_non_wsl_or_headless_runs() {
assert!(!should_warn_for_wsl_x11_desktop(false, true, false, false));
assert!(!should_warn_for_wsl_x11_desktop(true, false, false, false));
}
#[test]
fn wsl_x11_warning_skips_wslg_or_wayland_runs() {
assert!(!should_warn_for_wsl_x11_desktop(true, true, true, false));
assert!(!should_warn_for_wsl_x11_desktop(true, true, false, true));
}
// -------------------------------------------------------------------------
// Linux display-server pre-flight (Sentry OPENHUMAN-TAURI-K1)
// -------------------------------------------------------------------------
#[test]
fn linux_display_present_with_x11() {
assert!(linux_display_server_present(true, false));
}
#[test]
fn linux_display_present_with_wayland() {
assert!(linux_display_server_present(false, true));
}
#[test]
fn linux_display_present_with_both() {
assert!(linux_display_server_present(true, true));
}
#[test]
fn linux_display_absent_without_either() {
assert!(!linux_display_server_present(false, false));
}
#[test]
fn linux_root_uid_detected() {
assert!(linux_is_root_uid(0));
}
#[test]
fn linux_non_root_uid_not_detected() {
assert!(!linux_is_root_uid(1000));
assert!(!linux_is_root_uid(1));
}
// -------------------------------------------------------------------------
// Linux D-Bus session-bus probe (Sentry OPENHUMAN-TAURI-TM)
// -------------------------------------------------------------------------
#[test]
fn dbus_address_unix_is_supported() {
assert!(dbus_address_is_supported("unix:path=/run/user/1000/bus"));
assert!(dbus_address_is_supported("unix:abstract=/tmp/dbus-abc"));
}
#[test]
fn dbus_address_tcp_and_launchd_supported() {
assert!(dbus_address_is_supported("tcp:host=localhost,port=1234"));
assert!(dbus_address_is_supported(
"launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET"
));
assert!(dbus_address_is_supported("autolaunch:"));
}
#[test]
fn dbus_address_disabled_is_unsupported() {
// The literal value WSL2-without-WSLg sets — root cause of the panic.
assert!(!dbus_address_is_supported("disabled"));
assert!(!dbus_address_is_supported(""));
assert!(!dbus_address_is_supported(" "));
}
#[test]
fn dbus_address_unknown_transport_is_unsupported() {
assert!(!dbus_address_is_supported("nonce-tcp:host=localhost"));
assert!(!dbus_address_is_supported("bogus:"));
}
#[test]
fn dbus_address_picks_first_supported_in_list() {
// zbus walks the semicolon-separated list and uses the first reachable
// transport, so one good entry is enough.
assert!(dbus_address_is_supported(
"disabled;unix:path=/run/user/1000/bus"
));
assert!(dbus_address_is_supported(
"bogus:;tcp:host=localhost,port=55"
));
assert!(!dbus_address_is_supported("disabled;bogus:"));
}
#[test]
fn dbus_reachable_when_env_addr_is_supported() {
assert!(linux_dbus_session_reachable(
Some("unix:path=/run/user/1000/bus"),
false,
));
}
#[test]
fn dbus_unreachable_when_env_addr_disabled() {
// Even if the socket exists, an explicit `disabled` value means the
// session bus is intentionally turned off and zbus will reject it.
assert!(!linux_dbus_session_reachable(Some("disabled"), true));
}
#[test]
fn dbus_falls_back_to_runtime_socket_when_env_unset() {
assert!(linux_dbus_session_reachable(None, true));
assert!(!linux_dbus_session_reachable(None, false));
}
// -------------------------------------------------------------------------
// Platform constants (issue #1012 Sentry tagging)
// -------------------------------------------------------------------------
/// cpu_arch tag is derived from std::env::consts::ARCH which must be non-empty.
#[test]
fn platform_arch_constant_is_nonempty() {
assert!(
!std::env::consts::ARCH.is_empty(),
"ARCH constant used for Sentry cpu_arch tag must be non-empty"
);
}
/// os_name tag is derived from std::env::consts::OS which must be non-empty.
#[test]
fn platform_os_constant_is_nonempty() {
assert!(
!std::env::consts::OS.is_empty(),
"OS constant used for Sentry os_name tag must be non-empty"
);
}
/// On a macOS build the OS constant must equal "macos".
#[cfg(target_os = "macos")]
#[test]
fn platform_os_is_macos_on_macos_build() {
assert_eq!(std::env::consts::OS, "macos");
}
#[test]
fn platform_cef_gpu_workarounds_disable_linux_gpu_path() {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", None);
assert!(args.contains(&("--disable-gpu", None)));
assert!(args.contains(&("--disable-gpu-compositing", None)));
}
#[test]
fn platform_cef_gpu_workarounds_disable_intel_macos_compositing_only() {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", None);
assert_eq!(args, vec![("--disable-gpu-compositing", None)]);
}
#[test]
fn platform_cef_gpu_workarounds_leave_other_platforms_alone() {
for (os, arch) in [("macos", "aarch64"), ("windows", "x86_64")] {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, os, arch, None);
assert!(
args.is_empty(),
"unexpected CEF GPU flags for {os}/{arch}: {args:?}"
);
}
}
// -------------------------------------------------------------------------
// OPENHUMAN_FORCE_GPU override (re-enables WebGL2 surfaces — Rive mascot)
// -------------------------------------------------------------------------
#[test]
fn force_gpu_default_off_when_env_unset() {
assert!(!cef_force_gpu_enabled(None));
}
#[test]
fn force_gpu_explicit_enable_values_match_prewarm_pattern() {
for v in ["1", "true", "yes", "on", "TRUE", "Yes", "On"] {
assert!(
cef_force_gpu_enabled(Some(v)),
"OPENHUMAN_FORCE_GPU={v:?} should opt in"
);
}
}
#[test]
fn force_gpu_anything_else_is_off() {
// Mirrors prewarm semantics: only explicit truthy values opt in.
for v in ["", "0", "false", "no", "off", "FALSE", "Off", "maybe", " "] {
assert!(
!cef_force_gpu_enabled(Some(v)),
"OPENHUMAN_FORCE_GPU={v:?} must not silently opt in"
);
}
}
#[test]
fn platform_cef_gpu_workarounds_skip_linux_disable_when_force_gpu_set() {
// Assert the two GPU-disable flags are absent rather than the whole
// arg list being empty: on root-Linux runners (CI in some configs)
// the function still appends `--no-sandbox` via the orthogonal
// OPENHUMAN-TAURI-K1 branch, which would make a strict `is_empty()`
// check fail spuriously. We only care about the GPU branch here.
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", Some("1"));
assert!(
!args.contains(&("--disable-gpu", None)),
"OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu, got: {args:?}"
);
assert!(
!args.contains(&("--disable-gpu-compositing", None)),
"OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu-compositing, got: {args:?}"
);
}
#[test]
fn platform_cef_gpu_workarounds_force_gpu_does_not_affect_intel_macos_path() {
// OPENHUMAN_FORCE_GPU only governs the Linux #1697 workaround; the
// separate Intel-macOS #1012 disable must still apply, regardless of
// the env var.
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", Some("1"));
assert_eq!(args, vec![("--disable-gpu-compositing", None)]);
}
/// On an Intel macOS build the ARCH constant must equal "x86_64".
/// This is the architecture that triggers --disable-gpu-compositing.
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
#[test]
fn platform_arch_is_x86_64_on_intel_build() {
assert_eq!(std::env::consts::ARCH, "x86_64");
}
/// On Apple Silicon the ARCH constant must equal "aarch64"; the GPU flag
/// must NOT be compiled in (verified by this test existing in the binary).
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
#[test]
fn platform_arch_is_aarch64_on_apple_silicon_build() {
assert_eq!(std::env::consts::ARCH, "aarch64");
}
// -------------------------------------------------------------------------
// cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard)
// -------------------------------------------------------------------------
#[test]
fn prewarm_enabled_by_default_on_non_wayland() {
assert!(cef_prewarm_enabled(None, false));
}
#[test]
fn prewarm_auto_disabled_on_wayland_when_env_unset() {
assert!(!cef_prewarm_enabled(None, true));
}
#[test]
fn prewarm_explicit_disable_respected_on_non_wayland() {
assert!(!cef_prewarm_enabled(Some("0"), false));
assert!(!cef_prewarm_enabled(Some("false"), false));
assert!(!cef_prewarm_enabled(Some("no"), false));
assert!(!cef_prewarm_enabled(Some("off"), false));
}
#[test]
fn prewarm_explicit_disable_respected_on_wayland() {
assert!(!cef_prewarm_enabled(Some("0"), true));
assert!(!cef_prewarm_enabled(Some("false"), true));
}
#[test]
fn prewarm_explicit_enable_overrides_wayland_guard() {
// OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops
// force prewarm even on Wayland sessions.
assert!(cef_prewarm_enabled(Some("1"), true));
assert!(cef_prewarm_enabled(Some("true"), true));
assert!(cef_prewarm_enabled(Some("yes"), true));
assert!(cef_prewarm_enabled(Some("on"), true));
}
#[test]
fn prewarm_disable_flags_are_case_insensitive() {
assert!(!cef_prewarm_enabled(Some("FALSE"), false));
assert!(!cef_prewarm_enabled(Some("OFF"), true));
assert!(!cef_prewarm_enabled(Some(" 0 "), false));
assert!(!cef_prewarm_enabled(Some(" No "), true));
}
#[test]
fn prewarm_unknown_env_value_treated_as_enable() {
// Any string that is not a recognised disable token → treat as enable.
assert!(cef_prewarm_enabled(Some("enabled"), false));
assert!(cef_prewarm_enabled(Some("yes"), false));
assert!(cef_prewarm_enabled(Some(""), false));
}
// -------------------------------------------------------------------------
// build_sentry_release_tag
// -------------------------------------------------------------------------
#[test]
fn sentry_release_tag_starts_with_openhuman() {
let tag = build_sentry_release_tag();
assert!(
tag.starts_with("openhuman@"),
"release tag must start with 'openhuman@', got: {tag:?}"
);
}
#[test]
fn sentry_release_tag_contains_cargo_pkg_version() {
let tag = build_sentry_release_tag();
let version = env!("CARGO_PKG_VERSION");
assert!(
tag.contains(version),
"release tag {tag:?} must embed CARGO_PKG_VERSION {version:?}"
);
}
#[test]
fn sentry_release_tag_version_part_is_nonempty() {
let tag = build_sentry_release_tag();
let after_prefix = tag.strip_prefix("openhuman@").unwrap_or("");
assert!(!after_prefix.is_empty(), "version part must not be empty");
}
/// When a SHA is baked in the tag takes the form `openhuman@<ver>+<sha12>`.
/// When it is not, the tag is simply `openhuman@<ver>` with no `+`.
/// Either way the full tag must be non-empty.
#[test]
fn sentry_release_tag_is_nonempty() {
assert!(!build_sentry_release_tag().is_empty());
}
// -------------------------------------------------------------------------
// resolve_sentry_environment
// -------------------------------------------------------------------------
#[test]
fn sentry_environment_reads_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, "staging");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "staging");
}
#[test]
fn sentry_environment_trims_whitespace_from_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, " dev ");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "dev");
}
#[test]
fn sentry_environment_skips_empty_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, "");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
// Falls through to VITE_ compile-time value or "production"; must be non-empty.
assert!(!env.is_empty());
}
#[test]
fn sentry_environment_skips_whitespace_only_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, " ");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert!(!env.is_empty());
}
/// When neither runtime env var nor compile-time VITE_ is set, the fallback
/// must be "production". Guard with a compile-time check so this test only
/// asserts the hard default when no compile-time override is present.
#[test]
fn sentry_environment_defaults_to_production_when_unset() {
let _g = ENV_LOCK.lock().unwrap();
if option_env!("VITE_OPENHUMAN_APP_ENV").is_some() {
// A compile-time override is baked in; skip — the fallback path is
// exercised by sentry_environment_skips_empty_openhuman_app_env.
return;
}
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::remove_var(key);
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "production");
}
// ── Sentry before_send filter: drop "Failed to request http://localhost:…"
// noise emitted by the vendored tauri-runtime-cef dev proxy in packaged
// builds (issue OPENHUMAN-TAURI-V). Tests target the pure
// `message_is_localhost_dev_fetch_noise` helper so the rule can be
// asserted without standing up a Sentry client.
#[test]
fn localhost_dev_fetch_noise_drops_vite_dev_url_1420() {
// The exact message shape reported by the latest event tag in Sentry
// (URL repeated by reqwest's `error sending request for url (…)`).
let msg = "Failed to request http://localhost:1420/components/skills/SkillCard.tsx: \
error sending request for url (http://localhost:1420/components/skills/SkillCard.tsx)";
assert!(
message_is_localhost_dev_fetch_noise(msg),
"expected Vite dev-server fetch failure to be filtered"
);
}
#[test]
fn localhost_dev_fetch_noise_drops_127_0_0_1_dev_url() {
// Some environments resolve `localhost` to 127.0.0.1 at the reqwest
// layer; the formatted message can carry either spelling.
let msg = "Failed to request http://127.0.0.1:1420/index.html: \
error sending request for url (http://127.0.0.1:1420/index.html)";
assert!(
message_is_localhost_dev_fetch_noise(msg),
"expected 127.0.0.1 dev-server fetch failure to be filtered"
);
}
#[test]
fn localhost_dev_fetch_noise_passes_production_url_through() {
// Real upstream failures (e.g. backend API errors surfaced via the
// same `Failed to request …` wording elsewhere) must NOT be filtered —
// they're the high-signal events Sentry exists for.
let msg = "Failed to request https://api.openhuman.ai/v1/skills: \
error sending request for url (https://api.openhuman.ai/v1/skills)";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"production API errors must NOT be filtered out"
);
}
#[test]
fn localhost_dev_fetch_noise_passes_unrelated_localhost_messages() {
// The filter is anchored on the dev-proxy's exact prefix to avoid
// accidentally dropping any error that happens to mention localhost
// (e.g. core-sidecar transport errors logged from coreRpcClient).
let msg =
"[core_rpc] transport error: error sending request for url (http://localhost:7788/rpc)";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"non-tauri-cef localhost errors must NOT be filtered"
);
}
#[test]
fn event_filter_uses_message_field() {
// event-level coverage: when sentry-tracing populates
// `event.message` (default with `attach_stacktrace=false`), the
// filter should see the noise payload through the primary read
// path. Per graycyrus on PR #1545.
let mut event = sentry::protocol::Event::new();
event.message = Some("Failed to request http://localhost:1420/foo: timeout".into());
assert!(
event_is_localhost_dev_fetch_noise(&event),
"event.message read path must catch noise messages"
);
}
#[test]
fn event_filter_falls_back_to_last_exception_value() {
// event-level coverage: if `attach_stacktrace` is ever turned on,
// sentry-tracing populates `event.exception` instead of (or in
// addition to) `event.message`. Filter must still see the noise
// payload through the exception fallback. Per graycyrus on PR #1545.
let mut event = sentry::protocol::Event::new();
event.message = None;
event.exception.values.push(sentry::protocol::Exception {
ty: "log".into(),
value: Some("Failed to request http://localhost:1420/foo: timeout".into()),
..Default::default()
});
assert!(
event_is_localhost_dev_fetch_noise(&event),
"exception fallback must catch noise messages when event.message is absent"
);
}
#[test]
fn event_filter_passes_through_when_neither_field_matches() {
// Negative event-level case: no noise prefix in either field →
// event must NOT be filtered.
let mut event = sentry::protocol::Event::new();
event.message = Some("genuine production error".into());
event.exception.values.push(sentry::protocol::Exception {
ty: "log".into(),
value: Some("connection refused (10061)".into()),
..Default::default()
});
assert!(
!event_is_localhost_dev_fetch_noise(&event),
"legitimate production events must pass through"
);
}
#[test]
fn localhost_dev_fetch_noise_anchors_to_message_start() {
// CodeRabbit (PR #1545) caught that the predicate used
// `contains` rather than `starts_with`. Regression: a message
// that merely embeds the dev-proxy prefix later in its text
// must NOT be filtered — only messages that *begin* with it.
let msg = "User report: `Failed to request http://localhost:1420/foo` was logged earlier";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"messages that merely contain the dev-proxy prefix must NOT be filtered"
);
}
// -------------------------------------------------------------------------
// path_has_executable / deep-link xdg-mime pre-flight (OPENHUMAN-TAURI-AS)
// -------------------------------------------------------------------------
/// With a controlled `$PATH` containing one dir that holds a file named
/// `xdg-mime`, the lookup must succeed (mirrors a Linux desktop install
/// where xdg-utils ships the binary).
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_finds_file_on_path() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub");
std::env::set_var("PATH", dir.path());
assert!(
path_has_executable("xdg-mime"),
"must discover xdg-mime when present in a $PATH entry"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// With a controlled `$PATH` that does NOT contain `xdg-mime`, the lookup
/// must fail (mirrors WSL2 / minimal containers without xdg-utils — the
/// case OPENHUMAN-TAURI-AS protects against).
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_when_missing() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
// Intentionally do not create xdg-mime in `dir`.
std::env::set_var("PATH", dir.path());
assert!(
!path_has_executable("xdg-mime"),
"must return false when xdg-mime is not in any $PATH entry"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// When `$PATH` is unset entirely, the lookup must short-circuit to false
/// rather than panic or fall back to the cwd.
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_when_path_unset() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
std::env::remove_var("PATH");
assert!(
!path_has_executable("xdg-mime"),
"unset $PATH must yield false (skip register_all on the missing-xdg-utils branch)"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// Regression guard for OPENHUMAN-TAURI-5V: a Linux host with `xdg-mime`
/// installed but `update-desktop-database` missing must classify as
/// "skip register_all" — the pre-#5V code only checked `xdg-mime` and
/// would have entered the plugin call, which then fires the noisy
/// `Failed to run OS command \`update-desktop-database\`` internal log
/// that escapes to Sentry. The Wave-4 fix pre-flights every xdg-utils
/// binary the plugin shells out to; this test pins that contract by
/// checking each binary lookup independently with a `$PATH` that
/// contains only `xdg-mime`.
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_for_partial_xdg_utils_install() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
// Only `xdg-mime` exists; `update-desktop-database` and
// `xdg-icon-resource` are deliberately absent.
std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub");
std::env::set_var("PATH", dir.path());
assert!(
path_has_executable("xdg-mime"),
"xdg-mime stub must be discoverable in the partial-install $PATH"
);
assert!(
!path_has_executable("update-desktop-database"),
"partial xdg-utils install must NOT report update-desktop-database present (OPENHUMAN-TAURI-5V)"
);
assert!(
!path_has_executable("xdg-icon-resource"),
"partial xdg-utils install must NOT report xdg-icon-resource present"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// Regression guard for issue #2228: `tauri-plugin-single-instance` must
/// enable the `deep-link` feature so that second-launch deep-link payloads
/// (e.g. `openhuman://oauth/...` callbacks from Windows/Linux system
/// browsers) are forwarded into the primary instance. Without it, hot OAuth
/// callbacks silently no-op while only focusing the existing window.
#[test]
fn single_instance_dep_enables_deep_link_feature() {
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let manifest =
std::fs::read_to_string(&manifest_path).expect("read app/src-tauri/Cargo.toml");
let parsed: toml::Value = manifest.parse().expect("parse Cargo.toml");
let dep = parsed
.get("dependencies")
.and_then(|d| d.get("tauri-plugin-single-instance"))
.expect("tauri-plugin-single-instance dependency must exist");
let features = dep.get("features").and_then(|f| f.as_array()).expect(
"tauri-plugin-single-instance must be a table with a `features` array \
— issue #2228 requires the `deep-link` feature to forward hot-instance \
OAuth callbacks on Windows/Linux",
);
assert!(
features.iter().any(|v| v.as_str() == Some("deep-link")),
"tauri-plugin-single-instance must enable the `deep-link` feature \
(issue #2228 — hot-instance OAuth callback forwarding)"
);
}
}
#[path = "lib_tests.rs"]
mod tests;
+940
View File
@@ -0,0 +1,940 @@
use super::*;
// Tests that read/write process-global env vars must serialize through this
// mutex. Rust's test runner executes tests in parallel by default; without
// coordination, concurrent set_var / remove_var calls race and produce
// spurious failures.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Test that is_daemon_mode correctly detects daemon flag variations
#[test]
fn is_daemon_mode_detects_daemon_flag() {
// Note: This test relies on the current process args, so in test mode
// it will typically return false. We verify the function is callable.
let _result = is_daemon_mode();
}
/// Test core_rpc_url returns expected format
#[test]
fn core_rpc_url_returns_expected_format() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://localhost:9999/rpc");
let url = core_rpc_url();
assert_eq!(url, "http://localhost:9999/rpc");
std::env::remove_var("OPENHUMAN_CORE_RPC_URL");
let url = core_rpc_url();
assert_eq!(url, "http://127.0.0.1:7788/rpc");
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
/// Test overlay_parent_rpc_url handles empty env var
#[test]
fn overlay_parent_rpc_url_handles_empty() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var("OPENHUMAN_CORE_RPC_URL").ok();
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "");
assert!(overlay_parent_rpc_url().is_none());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", " ");
assert!(overlay_parent_rpc_url().is_none());
std::env::set_var("OPENHUMAN_CORE_RPC_URL", "http://127.0.0.1:7788/rpc");
assert_eq!(
overlay_parent_rpc_url(),
Some("http://127.0.0.1:7788/rpc".to_string())
);
match original {
Some(v) => std::env::set_var("OPENHUMAN_CORE_RPC_URL", v),
None => std::env::remove_var("OPENHUMAN_CORE_RPC_URL"),
}
}
#[test]
fn reset_local_data_windows_file_lock_error_codes_are_recognized() {
assert!(is_windows_file_lock_raw_os_error(Some(32)));
assert!(is_windows_file_lock_raw_os_error(Some(33)));
assert!(!is_windows_file_lock_raw_os_error(Some(5)));
assert!(!is_windows_file_lock_raw_os_error(None));
}
#[test]
fn reset_local_data_delete_error_keeps_generic_message_for_other_errors() {
let err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let result = reset_local_data_delete_error(
"current openhuman dir",
std::path::Path::new("/tmp/openhuman"),
&err,
);
let msg = result.expect_err("non-lock errors must still surface to the UI");
assert!(msg.starts_with("Failed to remove current openhuman dir at /tmp/openhuman:"));
assert!(!msg.contains("Close all OpenHuman windows and try again"));
}
#[cfg(windows)]
#[test]
fn reset_local_data_delete_error_swallows_lock_failure_when_path_disappeared() {
// Race condition the reboot fallback now handles: the locked path
// was gone by the time `schedule_path_for_reboot_deletion` ran its
// `symlink_metadata` probe, so the reset goal is already met. The
// helper must return `Ok(())` rather than surfacing a confusing
// "couldn't remove (it's not there)" toast.
let dir = tempfile::tempdir().expect("tempdir for reset error test");
let missing = dir.path().join("definitely-not-there");
let err = std::io::Error::from_raw_os_error(32);
let result = reset_local_data_delete_error("current openhuman dir", &missing, &err);
assert!(
result.is_ok(),
"expected NotFound + empty partial schedule to be swallowed as success, got {result:?}"
);
}
#[cfg(windows)]
#[test]
fn reset_local_data_delete_error_reports_reboot_schedule_counts() {
// When the lock fallback can walk a real directory tree, the user
// message should report how much has been queued so the support
// log preserves "what was actually scheduled". Scheduling itself
// may still fail at the MoveFileExW step in unprivileged test
// processes (the registry key write requires administrator); the
// fallback then carries a partial schedule that the error path
// surfaces, so both branches must keep mentioning the lock cause
// *and* expose either the queued counts or the schedule failure.
let dir = tempfile::tempdir().expect("tempdir for reset error test");
let target = dir.path().join("reset-mock");
std::fs::create_dir_all(target.join("nested")).expect("mkdir nested");
std::fs::write(target.join("a.txt"), b"x").expect("write a.txt");
std::fs::write(target.join("nested").join("b.txt"), b"y").expect("write b.txt");
let err = std::io::Error::from_raw_os_error(32);
let result = reset_local_data_delete_error("current openhuman dir", &target, &err);
// Path exists on disk, so the fallback must surface the outcome —
// either an "all-queued" success-but-needs-reboot message (admin)
// or one of the failure flavours (non-admin).
let msg =
result.expect_err("path exists, fallback must report queued counts or scheduling failure");
let admin_path = msg.contains("queued for deletion the next time you restart Windows")
&& msg.contains("2 files and 2 folders");
let user_full_fail = msg.contains("scheduling deletion on next reboot also failed");
let user_partial = msg.contains("queued for the next reboot before scheduling failed");
assert!(
admin_path || user_full_fail || user_partial,
"expected reboot-scheduled, fully-failed, or partial-fail message, got: {msg}"
);
// Whatever branch we land on, the user must still be told the lock
// is what blocked the immediate removal.
assert!(
msg.contains("locked by another OpenHuman window or process")
|| msg.contains("another process is holding it open"),
"missing lock cause: {msg}"
);
}
/// Tests for setup_tray conditional compilation
/// The PR adds two versions of setup_tray():
/// 1. No-op for linux + cef: logs warning and returns Ok(())
/// 2. Full implementation for other platforms
///
/// These tests verify the function signatures are correct and
/// the compile-time cfg blocks are properly set up.
/// Verify setup_tray function exists and has correct signature
/// This test passes if the code compiles, as the function signature
/// is validated by the compiler.
#[test]
fn setup_tray_function_signature_compiles() {
// This test exists to ensure the conditional compilation
// of setup_tray is valid. The function is not actually called
// here because it requires a full Tauri AppHandle.
// The cfg attributes ensure only one version exists at compile time.
}
/// Test that AppRuntime is defined for the current feature set
#[test]
fn app_runtime_type_exists() {
// This test verifies AppRuntime is properly defined
// based on the cef feature flag.
// The type alias exists at module scope and is used throughout.
fn _check_runtime<R: tauri::Runtime>() {}
// _check_runtime::<AppRuntime>(); // Would require importing
}
#[test]
fn no_app_update_available_result_is_quiet_unavailable() {
let info = no_app_update_available("0.53.43".to_string());
assert_eq!(info.current_version, "0.53.43");
assert!(!info.available);
assert!(info.available_version.is_none());
assert!(info.body.is_none());
}
/// Verify tray logging patterns exist (grep-friendly)
#[test]
fn tray_setup_logging_patterns_exist() {
// These log patterns from the PR are grep-friendly:
// "[tray] skipping tray setup on linux+cef: ..."
// "[tray] setting up tray icon"
// "[tray] tray icon ready"
// "[tray] action=show_window ..."
// "[tray] action=quit ..."
// "[tray] failed to setup tray icon ..."
// "[app] RunEvent::Ready — GTK initialized, setting up tray"
//
// This test passes if the code compiles with these log messages.
}
// -------------------------------------------------------------------------
// macos_os_version (issue #1012)
// -------------------------------------------------------------------------
/// On macOS, sw_vers is always present and must return a version string.
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_returns_some() {
assert!(
macos_os_version().is_some(),
"sw_vers -productVersion must succeed on macOS"
);
}
/// The returned version must be a non-empty trimmed string.
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_is_nonempty() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
assert!(!ver.is_empty());
// No leading/trailing whitespace (the impl trims).
assert_eq!(ver, ver.trim());
}
/// The version string must look like dot-separated integers ("14.5", "13.2.1").
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_is_dotted_integer_format() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
let all_numeric_parts = ver
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
assert!(
all_numeric_parts,
"os version {ver:?} must be dot-separated integers (e.g. '14.5')"
);
}
/// The version must have at least one component (e.g. a bare major "15" is valid).
#[cfg(target_os = "macos")]
#[test]
fn macos_os_version_has_at_least_one_component() {
let ver = macos_os_version().expect("sw_vers must return a version on macOS");
assert!(
!ver.split('.').next().unwrap_or("").is_empty(),
"version must have at least one numeric component"
);
}
// -------------------------------------------------------------------------
// WSL + X11 desktop startup warning (issue #1653)
// -------------------------------------------------------------------------
#[test]
fn wsl_x11_warning_detects_classic_x11_forwarding() {
assert!(should_warn_for_wsl_x11_desktop(true, true, false, false));
}
#[test]
fn wsl_x11_warning_skips_non_wsl_or_headless_runs() {
assert!(!should_warn_for_wsl_x11_desktop(false, true, false, false));
assert!(!should_warn_for_wsl_x11_desktop(true, false, false, false));
}
#[test]
fn wsl_x11_warning_skips_wslg_or_wayland_runs() {
assert!(!should_warn_for_wsl_x11_desktop(true, true, true, false));
assert!(!should_warn_for_wsl_x11_desktop(true, true, false, true));
}
// -------------------------------------------------------------------------
// Linux display-server pre-flight (Sentry OPENHUMAN-TAURI-K1)
// -------------------------------------------------------------------------
#[test]
fn linux_display_present_with_x11() {
assert!(linux_display_server_present(true, false));
}
#[test]
fn linux_display_present_with_wayland() {
assert!(linux_display_server_present(false, true));
}
#[test]
fn linux_display_present_with_both() {
assert!(linux_display_server_present(true, true));
}
#[test]
fn linux_display_absent_without_either() {
assert!(!linux_display_server_present(false, false));
}
#[test]
fn linux_root_uid_detected() {
assert!(linux_is_root_uid(0));
}
#[test]
fn linux_non_root_uid_not_detected() {
assert!(!linux_is_root_uid(1000));
assert!(!linux_is_root_uid(1));
}
// -------------------------------------------------------------------------
// Linux D-Bus session-bus probe (Sentry OPENHUMAN-TAURI-TM)
// -------------------------------------------------------------------------
#[test]
fn dbus_address_unix_is_supported() {
assert!(dbus_address_is_supported("unix:path=/run/user/1000/bus"));
assert!(dbus_address_is_supported("unix:abstract=/tmp/dbus-abc"));
}
#[test]
fn dbus_address_tcp_and_launchd_supported() {
assert!(dbus_address_is_supported("tcp:host=localhost,port=1234"));
assert!(dbus_address_is_supported(
"launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET"
));
assert!(dbus_address_is_supported("autolaunch:"));
}
#[test]
fn dbus_address_disabled_is_unsupported() {
// The literal value WSL2-without-WSLg sets — root cause of the panic.
assert!(!dbus_address_is_supported("disabled"));
assert!(!dbus_address_is_supported(""));
assert!(!dbus_address_is_supported(" "));
}
#[test]
fn dbus_address_unknown_transport_is_unsupported() {
assert!(!dbus_address_is_supported("nonce-tcp:host=localhost"));
assert!(!dbus_address_is_supported("bogus:"));
}
#[test]
fn dbus_address_picks_first_supported_in_list() {
// zbus walks the semicolon-separated list and uses the first reachable
// transport, so one good entry is enough.
assert!(dbus_address_is_supported(
"disabled;unix:path=/run/user/1000/bus"
));
assert!(dbus_address_is_supported(
"bogus:;tcp:host=localhost,port=55"
));
assert!(!dbus_address_is_supported("disabled;bogus:"));
}
#[test]
fn dbus_reachable_when_env_addr_is_supported() {
assert!(linux_dbus_session_reachable(
Some("unix:path=/run/user/1000/bus"),
false,
));
}
#[test]
fn dbus_unreachable_when_env_addr_disabled() {
// Even if the socket exists, an explicit `disabled` value means the
// session bus is intentionally turned off and zbus will reject it.
assert!(!linux_dbus_session_reachable(Some("disabled"), true));
}
#[test]
fn dbus_falls_back_to_runtime_socket_when_env_unset() {
assert!(linux_dbus_session_reachable(None, true));
assert!(!linux_dbus_session_reachable(None, false));
}
// -------------------------------------------------------------------------
// Platform constants (issue #1012 Sentry tagging)
// -------------------------------------------------------------------------
/// cpu_arch tag is derived from std::env::consts::ARCH which must be non-empty.
#[test]
fn platform_arch_constant_is_nonempty() {
assert!(
!std::env::consts::ARCH.is_empty(),
"ARCH constant used for Sentry cpu_arch tag must be non-empty"
);
}
/// os_name tag is derived from std::env::consts::OS which must be non-empty.
#[test]
fn platform_os_constant_is_nonempty() {
assert!(
!std::env::consts::OS.is_empty(),
"OS constant used for Sentry os_name tag must be non-empty"
);
}
/// On a macOS build the OS constant must equal "macos".
#[cfg(target_os = "macos")]
#[test]
fn platform_os_is_macos_on_macos_build() {
assert_eq!(std::env::consts::OS, "macos");
}
#[test]
fn platform_cef_gpu_workarounds_disable_linux_gpu_path() {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", None);
assert!(args.contains(&("--disable-gpu", None)));
assert!(args.contains(&("--disable-gpu-compositing", None)));
}
#[test]
fn platform_cef_gpu_workarounds_disable_intel_macos_compositing_only() {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", None);
assert_eq!(args, vec![("--disable-gpu-compositing", None)]);
}
#[test]
fn platform_cef_gpu_workarounds_leave_other_platforms_alone() {
for (os, arch) in [("macos", "aarch64"), ("windows", "x86_64")] {
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, os, arch, None);
assert!(
args.is_empty(),
"unexpected CEF GPU flags for {os}/{arch}: {args:?}"
);
}
}
// -------------------------------------------------------------------------
// OPENHUMAN_FORCE_GPU override (re-enables WebGL2 surfaces — Rive mascot)
// -------------------------------------------------------------------------
#[test]
fn force_gpu_default_off_when_env_unset() {
assert!(!cef_force_gpu_enabled(None));
}
#[test]
fn force_gpu_explicit_enable_values_match_prewarm_pattern() {
for v in ["1", "true", "yes", "on", "TRUE", "Yes", "On"] {
assert!(
cef_force_gpu_enabled(Some(v)),
"OPENHUMAN_FORCE_GPU={v:?} should opt in"
);
}
}
#[test]
fn force_gpu_anything_else_is_off() {
// Mirrors prewarm semantics: only explicit truthy values opt in.
for v in ["", "0", "false", "no", "off", "FALSE", "Off", "maybe", " "] {
assert!(
!cef_force_gpu_enabled(Some(v)),
"OPENHUMAN_FORCE_GPU={v:?} must not silently opt in"
);
}
}
#[test]
fn platform_cef_gpu_workarounds_skip_linux_disable_when_force_gpu_set() {
// Assert the two GPU-disable flags are absent rather than the whole
// arg list being empty: on root-Linux runners (CI in some configs)
// the function still appends `--no-sandbox` via the orthogonal
// OPENHUMAN-TAURI-K1 branch, which would make a strict `is_empty()`
// check fail spuriously. We only care about the GPU branch here.
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "linux", "x86_64", Some("1"));
assert!(
!args.contains(&("--disable-gpu", None)),
"OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu, got: {args:?}"
);
assert!(
!args.contains(&("--disable-gpu-compositing", None)),
"OPENHUMAN_FORCE_GPU=1 must suppress --disable-gpu-compositing, got: {args:?}"
);
}
#[test]
fn platform_cef_gpu_workarounds_force_gpu_does_not_affect_intel_macos_path() {
// OPENHUMAN_FORCE_GPU only governs the Linux #1697 workaround; the
// separate Intel-macOS #1012 disable must still apply, regardless of
// the env var.
let mut args = Vec::new();
append_platform_cef_gpu_workarounds(&mut args, "macos", "x86_64", Some("1"));
assert_eq!(args, vec![("--disable-gpu-compositing", None)]);
}
/// On an Intel macOS build the ARCH constant must equal "x86_64".
/// This is the architecture that triggers --disable-gpu-compositing.
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
#[test]
fn platform_arch_is_x86_64_on_intel_build() {
assert_eq!(std::env::consts::ARCH, "x86_64");
}
/// On Apple Silicon the ARCH constant must equal "aarch64"; the GPU flag
/// must NOT be compiled in (verified by this test existing in the binary).
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
#[test]
fn platform_arch_is_aarch64_on_apple_silicon_build() {
assert_eq!(std::env::consts::ARCH, "aarch64");
}
// -------------------------------------------------------------------------
// cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard)
// -------------------------------------------------------------------------
#[test]
fn prewarm_enabled_by_default_on_non_wayland() {
assert!(cef_prewarm_enabled(None, false));
}
#[test]
fn prewarm_auto_disabled_on_wayland_when_env_unset() {
assert!(!cef_prewarm_enabled(None, true));
}
#[test]
fn prewarm_explicit_disable_respected_on_non_wayland() {
assert!(!cef_prewarm_enabled(Some("0"), false));
assert!(!cef_prewarm_enabled(Some("false"), false));
assert!(!cef_prewarm_enabled(Some("no"), false));
assert!(!cef_prewarm_enabled(Some("off"), false));
}
#[test]
fn prewarm_explicit_disable_respected_on_wayland() {
assert!(!cef_prewarm_enabled(Some("0"), true));
assert!(!cef_prewarm_enabled(Some("false"), true));
}
#[test]
fn prewarm_explicit_enable_overrides_wayland_guard() {
// OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops
// force prewarm even on Wayland sessions.
assert!(cef_prewarm_enabled(Some("1"), true));
assert!(cef_prewarm_enabled(Some("true"), true));
assert!(cef_prewarm_enabled(Some("yes"), true));
assert!(cef_prewarm_enabled(Some("on"), true));
}
#[test]
fn prewarm_disable_flags_are_case_insensitive() {
assert!(!cef_prewarm_enabled(Some("FALSE"), false));
assert!(!cef_prewarm_enabled(Some("OFF"), true));
assert!(!cef_prewarm_enabled(Some(" 0 "), false));
assert!(!cef_prewarm_enabled(Some(" No "), true));
}
#[test]
fn prewarm_unknown_env_value_treated_as_enable() {
// Any string that is not a recognised disable token → treat as enable.
assert!(cef_prewarm_enabled(Some("enabled"), false));
assert!(cef_prewarm_enabled(Some("yes"), false));
assert!(cef_prewarm_enabled(Some(""), false));
}
// -------------------------------------------------------------------------
// build_sentry_release_tag
// -------------------------------------------------------------------------
#[test]
fn sentry_release_tag_starts_with_openhuman() {
let tag = build_sentry_release_tag();
assert!(
tag.starts_with("openhuman@"),
"release tag must start with 'openhuman@', got: {tag:?}"
);
}
#[test]
fn sentry_release_tag_contains_cargo_pkg_version() {
let tag = build_sentry_release_tag();
let version = env!("CARGO_PKG_VERSION");
assert!(
tag.contains(version),
"release tag {tag:?} must embed CARGO_PKG_VERSION {version:?}"
);
}
#[test]
fn sentry_release_tag_version_part_is_nonempty() {
let tag = build_sentry_release_tag();
let after_prefix = tag.strip_prefix("openhuman@").unwrap_or("");
assert!(!after_prefix.is_empty(), "version part must not be empty");
}
/// When a SHA is baked in the tag takes the form `openhuman@<ver>+<sha12>`.
/// When it is not, the tag is simply `openhuman@<ver>` with no `+`.
/// Either way the full tag must be non-empty.
#[test]
fn sentry_release_tag_is_nonempty() {
assert!(!build_sentry_release_tag().is_empty());
}
// -------------------------------------------------------------------------
// resolve_sentry_environment
// -------------------------------------------------------------------------
#[test]
fn sentry_environment_reads_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, "staging");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "staging");
}
#[test]
fn sentry_environment_trims_whitespace_from_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, " dev ");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "dev");
}
#[test]
fn sentry_environment_skips_empty_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, "");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
// Falls through to VITE_ compile-time value or "production"; must be non-empty.
assert!(!env.is_empty());
}
#[test]
fn sentry_environment_skips_whitespace_only_openhuman_app_env() {
let _g = ENV_LOCK.lock().unwrap();
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::set_var(key, " ");
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert!(!env.is_empty());
}
/// When neither runtime env var nor compile-time VITE_ is set, the fallback
/// must be "production". Guard with a compile-time check so this test only
/// asserts the hard default when no compile-time override is present.
#[test]
fn sentry_environment_defaults_to_production_when_unset() {
let _g = ENV_LOCK.lock().unwrap();
if option_env!("VITE_OPENHUMAN_APP_ENV").is_some() {
// A compile-time override is baked in; skip — the fallback path is
// exercised by sentry_environment_skips_empty_openhuman_app_env.
return;
}
let key = "OPENHUMAN_APP_ENV";
let original = std::env::var(key).ok();
std::env::remove_var(key);
let env = resolve_sentry_environment();
match original {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(env, "production");
}
// ── Sentry before_send filter: drop "Failed to request http://localhost:…"
// noise emitted by the vendored tauri-runtime-cef dev proxy in packaged
// builds (issue OPENHUMAN-TAURI-V). Tests target the pure
// `message_is_localhost_dev_fetch_noise` helper so the rule can be
// asserted without standing up a Sentry client.
#[test]
fn localhost_dev_fetch_noise_drops_vite_dev_url_1420() {
// The exact message shape reported by the latest event tag in Sentry
// (URL repeated by reqwest's `error sending request for url (…)`).
let msg = "Failed to request http://localhost:1420/components/skills/SkillCard.tsx: \
error sending request for url (http://localhost:1420/components/skills/SkillCard.tsx)";
assert!(
message_is_localhost_dev_fetch_noise(msg),
"expected Vite dev-server fetch failure to be filtered"
);
}
#[test]
fn localhost_dev_fetch_noise_drops_127_0_0_1_dev_url() {
// Some environments resolve `localhost` to 127.0.0.1 at the reqwest
// layer; the formatted message can carry either spelling.
let msg = "Failed to request http://127.0.0.1:1420/index.html: \
error sending request for url (http://127.0.0.1:1420/index.html)";
assert!(
message_is_localhost_dev_fetch_noise(msg),
"expected 127.0.0.1 dev-server fetch failure to be filtered"
);
}
#[test]
fn localhost_dev_fetch_noise_passes_production_url_through() {
// Real upstream failures (e.g. backend API errors surfaced via the
// same `Failed to request …` wording elsewhere) must NOT be filtered —
// they're the high-signal events Sentry exists for.
let msg = "Failed to request https://api.openhuman.ai/v1/skills: \
error sending request for url (https://api.openhuman.ai/v1/skills)";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"production API errors must NOT be filtered out"
);
}
#[test]
fn localhost_dev_fetch_noise_passes_unrelated_localhost_messages() {
// The filter is anchored on the dev-proxy's exact prefix to avoid
// accidentally dropping any error that happens to mention localhost
// (e.g. core-sidecar transport errors logged from coreRpcClient).
let msg =
"[core_rpc] transport error: error sending request for url (http://localhost:7788/rpc)";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"non-tauri-cef localhost errors must NOT be filtered"
);
}
#[test]
fn event_filter_uses_message_field() {
// event-level coverage: when sentry-tracing populates
// `event.message` (default with `attach_stacktrace=false`), the
// filter should see the noise payload through the primary read
// path. Per graycyrus on PR #1545.
let mut event = sentry::protocol::Event::new();
event.message = Some("Failed to request http://localhost:1420/foo: timeout".into());
assert!(
event_is_localhost_dev_fetch_noise(&event),
"event.message read path must catch noise messages"
);
}
#[test]
fn event_filter_falls_back_to_last_exception_value() {
// event-level coverage: if `attach_stacktrace` is ever turned on,
// sentry-tracing populates `event.exception` instead of (or in
// addition to) `event.message`. Filter must still see the noise
// payload through the exception fallback. Per graycyrus on PR #1545.
let mut event = sentry::protocol::Event::new();
event.message = None;
event.exception.values.push(sentry::protocol::Exception {
ty: "log".into(),
value: Some("Failed to request http://localhost:1420/foo: timeout".into()),
..Default::default()
});
assert!(
event_is_localhost_dev_fetch_noise(&event),
"exception fallback must catch noise messages when event.message is absent"
);
}
#[test]
fn event_filter_passes_through_when_neither_field_matches() {
// Negative event-level case: no noise prefix in either field →
// event must NOT be filtered.
let mut event = sentry::protocol::Event::new();
event.message = Some("genuine production error".into());
event.exception.values.push(sentry::protocol::Exception {
ty: "log".into(),
value: Some("connection refused (10061)".into()),
..Default::default()
});
assert!(
!event_is_localhost_dev_fetch_noise(&event),
"legitimate production events must pass through"
);
}
#[test]
fn localhost_dev_fetch_noise_anchors_to_message_start() {
// CodeRabbit (PR #1545) caught that the predicate used
// `contains` rather than `starts_with`. Regression: a message
// that merely embeds the dev-proxy prefix later in its text
// must NOT be filtered — only messages that *begin* with it.
let msg = "User report: `Failed to request http://localhost:1420/foo` was logged earlier";
assert!(
!message_is_localhost_dev_fetch_noise(msg),
"messages that merely contain the dev-proxy prefix must NOT be filtered"
);
}
// -------------------------------------------------------------------------
// path_has_executable / deep-link xdg-mime pre-flight (OPENHUMAN-TAURI-AS)
// -------------------------------------------------------------------------
/// With a controlled `$PATH` containing one dir that holds a file named
/// `xdg-mime`, the lookup must succeed (mirrors a Linux desktop install
/// where xdg-utils ships the binary).
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_finds_file_on_path() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub");
std::env::set_var("PATH", dir.path());
assert!(
path_has_executable("xdg-mime"),
"must discover xdg-mime when present in a $PATH entry"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// With a controlled `$PATH` that does NOT contain `xdg-mime`, the lookup
/// must fail (mirrors WSL2 / minimal containers without xdg-utils — the
/// case OPENHUMAN-TAURI-AS protects against).
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_when_missing() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
// Intentionally do not create xdg-mime in `dir`.
std::env::set_var("PATH", dir.path());
assert!(
!path_has_executable("xdg-mime"),
"must return false when xdg-mime is not in any $PATH entry"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// When `$PATH` is unset entirely, the lookup must short-circuit to false
/// rather than panic or fall back to the cwd.
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_when_path_unset() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
std::env::remove_var("PATH");
assert!(
!path_has_executable("xdg-mime"),
"unset $PATH must yield false (skip register_all on the missing-xdg-utils branch)"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// Regression guard for OPENHUMAN-TAURI-5V: a Linux host with `xdg-mime`
/// installed but `update-desktop-database` missing must classify as
/// "skip register_all" — the pre-#5V code only checked `xdg-mime` and
/// would have entered the plugin call, which then fires the noisy
/// `Failed to run OS command \`update-desktop-database\`` internal log
/// that escapes to Sentry. The Wave-4 fix pre-flights every xdg-utils
/// binary the plugin shells out to; this test pins that contract by
/// checking each binary lookup independently with a `$PATH` that
/// contains only `xdg-mime`.
#[cfg(target_os = "linux")]
#[test]
fn path_has_executable_returns_false_for_partial_xdg_utils_install() {
let _g = ENV_LOCK.lock().unwrap();
let original = std::env::var_os("PATH");
let dir = tempfile::tempdir().expect("tempdir");
// Only `xdg-mime` exists; `update-desktop-database` and
// `xdg-icon-resource` are deliberately absent.
std::fs::write(dir.path().join("xdg-mime"), b"#!/bin/sh\n").expect("write stub");
std::env::set_var("PATH", dir.path());
assert!(
path_has_executable("xdg-mime"),
"xdg-mime stub must be discoverable in the partial-install $PATH"
);
assert!(
!path_has_executable("update-desktop-database"),
"partial xdg-utils install must NOT report update-desktop-database present (OPENHUMAN-TAURI-5V)"
);
assert!(
!path_has_executable("xdg-icon-resource"),
"partial xdg-utils install must NOT report xdg-icon-resource present"
);
match original {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
/// Regression guard for issue #2228: `tauri-plugin-single-instance` must
/// enable the `deep-link` feature so that second-launch deep-link payloads
/// (e.g. `openhuman://oauth/...` callbacks from Windows/Linux system
/// browsers) are forwarded into the primary instance. Without it, hot OAuth
/// callbacks silently no-op while only focusing the existing window.
#[test]
fn single_instance_dep_enables_deep_link_feature() {
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let manifest = std::fs::read_to_string(&manifest_path).expect("read app/src-tauri/Cargo.toml");
let parsed: toml::Value = manifest.parse().expect("parse Cargo.toml");
let dep = parsed
.get("dependencies")
.and_then(|d| d.get("tauri-plugin-single-instance"))
.expect("tauri-plugin-single-instance dependency must exist");
let features = dep.get("features").and_then(|f| f.as_array()).expect(
"tauri-plugin-single-instance must be a table with a `features` array \
— issue #2228 requires the `deep-link` feature to forward hot-instance \
OAuth callbacks on Windows/Linux",
);
assert!(
features.iter().any(|v| v.as_str() == Some("deep-link")),
"tauri-plugin-single-instance must enable the `deep-link` feature \
(issue #2228 — hot-instance OAuth callback forwarding)"
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -613
View File
@@ -33,7 +33,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
mod dom_snapshot;
#[cfg(test)]
mod dom_snapshot_test;
mod dom_snapshot_tests;
mod idb;
const CDP_HOST: &str = "127.0.0.1";
@@ -1479,615 +1479,5 @@ impl ScannerRegistry {
}
#[cfg(test)]
mod tests {
use super::*;
// ── Issue #1376 — chat-name normalization for active-chat → JID lookup ──
#[test]
fn normalize_chat_name_strips_punctuation_and_emoji() {
// Group titles routinely pick up emoji + punctuation drift between
// the DOM-parsed conversation header and the IDB-stored chat name.
// Normalization should collapse both sides to the same key so the
// lookup at scan_once succeeds.
assert_eq!(
normalize_chat_name("17-18-19 July samagam"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17 18 19 July samagam"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17-18-19 July samagam ✨"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17.18.19 July, samagam!"),
"171819julysamagam"
);
// Identity property — already-normal strings round-trip unchanged.
assert_eq!(normalize_chat_name("foo123"), "foo123");
// Empty input → empty output (caller guards against this).
assert_eq!(normalize_chat_name(""), "");
assert_eq!(normalize_chat_name(" "), "");
assert_eq!(normalize_chat_name(""), "");
}
#[test]
fn normalize_chat_name_lowercases() {
assert_eq!(normalize_chat_name("Hello World"), "helloworld");
assert_eq!(normalize_chat_name("HELLO"), "hello");
assert_eq!(normalize_chat_name("hElLo"), "hello");
}
fn insert_pending_tasks(
registry: &ScannerRegistry,
account_id: &str,
count: usize,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut tasks = Vec::with_capacity(count);
let mut abort_handles = Vec::with_capacity(count);
for _ in 0..count {
let task = tokio::spawn(async {
std::future::pending::<()>().await;
});
abort_handles.push(task.abort_handle());
tasks.push(task);
}
registry
.started
.lock()
.insert(account_id.to_string(), abort_handles);
tasks
}
async fn assert_cancelled(task: tokio::task::JoinHandle<()>) {
let err = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("aborted scanner task should finish")
.expect_err("scanner task should be cancelled");
assert!(err.is_cancelled());
}
async fn assert_all_cancelled(tasks: Vec<tokio::task::JoinHandle<()>>) {
for task in tasks {
assert_cancelled(task).await;
}
}
#[tokio::test]
async fn registry_forget_aborts_all_handles_for_account_only() {
let registry = ScannerRegistry::default();
let account_tasks = insert_pending_tasks(&registry, "acct-1", 2);
let survivor_tasks = insert_pending_tasks(&registry, "acct-2", 1);
registry.forget("acct-1");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-2"));
}
assert_all_cancelled(account_tasks).await;
assert!(
!survivor_tasks[0].is_finished(),
"forget(acct-1) must not abort acct-2"
);
assert_eq!(registry.forget_all(), 1);
assert_all_cancelled(survivor_tasks).await;
}
#[tokio::test]
async fn registry_forget_missing_account_is_noop() {
let registry = ScannerRegistry::default();
let mut tasks = insert_pending_tasks(&registry, "acct-1", 1);
registry.forget("missing");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-1"));
}
assert!(
!tasks[0].is_finished(),
"forget(missing) must not abort existing scanners"
);
registry.forget("acct-1");
assert_cancelled(tasks.pop().expect("task")).await;
}
#[tokio::test]
async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() {
let registry = ScannerRegistry::default();
let task_a = insert_pending_tasks(&registry, "acct-1", 2);
let task_b = insert_pending_tasks(&registry, "acct-2", 3);
assert_eq!(registry.forget_all(), 5);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(task_a).await;
assert_all_cancelled(task_b).await;
}
#[tokio::test]
async fn registry_forget_all_is_repeatable_noop_after_drain() {
let registry = ScannerRegistry::default();
assert_eq!(registry.forget_all(), 0);
let tasks = insert_pending_tasks(&registry, "acct-1", 1);
assert_eq!(registry.forget_all(), 1);
assert_eq!(registry.forget_all(), 0);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(tasks).await;
}
// ── seconds_to_ymd ────────────────────────────────────────────────────────
#[test]
fn seconds_to_ymd_known_timestamp() {
// Unix timestamp 1_700_000_000 = 2023-11-14 (UTC).
assert_eq!(seconds_to_ymd(1_700_000_000), "2023-11-14");
}
#[test]
fn seconds_to_ymd_epoch_zero() {
// Unix epoch origin = 1970-01-01.
assert_eq!(seconds_to_ymd(0), "1970-01-01");
}
#[test]
fn seconds_to_ymd_output_format_is_yyyy_mm_dd() {
let s = seconds_to_ymd(1_700_000_000);
// Must match YYYY-MM-DD: 10 chars, digit/digit/digit/digit-...-...
assert_eq!(s.len(), 10, "expected 10-char date string, got: {s}");
let parts: Vec<&str> = s.split('-').collect();
assert_eq!(parts.len(), 3, "expected 3 dash-separated parts: {s}");
assert_eq!(parts[0].len(), 4, "year must be 4 digits: {s}");
assert_eq!(parts[1].len(), 2, "month must be 2 digits: {s}");
assert_eq!(parts[2].len(), 2, "day must be 2 digits: {s}");
assert!(
parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())),
"all parts must be numeric: {s}"
);
}
// ── parse_pre_timestamp_ymd ───────────────────────────────────────────────
#[test]
fn parse_pre_timestamp_ymd_valid_wa_format() {
// WhatsApp Web format: "4:53 AM, 7/5/2025"
let result = parse_pre_timestamp_ymd("4:53 AM, 7/5/2025");
assert_eq!(result.as_deref(), Some("2025-07-05"));
}
#[test]
fn parse_pre_timestamp_ymd_another_valid_date() {
// "10:01 PM, 11/14/2023" — matches our known ts
let result = parse_pre_timestamp_ymd("10:01 PM, 11/14/2023");
assert_eq!(result.as_deref(), Some("2023-11-14"));
}
#[test]
fn parse_pre_timestamp_ymd_empty_string_returns_none() {
assert!(parse_pre_timestamp_ymd("").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_no_comma_returns_none() {
assert!(parse_pre_timestamp_ymd("4:53 AM 7/5/2025").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_invalid_date_parts_return_none() {
// Month 13 is out of range.
assert!(parse_pre_timestamp_ymd("10:00 AM, 13/5/2025").is_none());
// Day 32 is out of range.
assert!(parse_pre_timestamp_ymd("10:00 AM, 1/32/2025").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_garbage_returns_none() {
assert!(parse_pre_timestamp_ymd("not a timestamp at all").is_none());
}
// ── emit_grouped_whatsapp grouping ────────────────────────────────────────
/// Build a minimal message Value that `emit_grouped_whatsapp` will accept.
fn make_msg(chat_id: &str, ts: i64, body: &str, from_me: bool) -> Value {
json!({
"chatId": chat_id,
"body": body,
"timestamp": ts,
"fromMe": from_me,
"from": if from_me { "me" } else { chat_id },
})
}
#[test]
fn grouping_produces_correct_group_count_and_keys() {
use std::collections::HashMap;
// 3 messages in alice@c.us on day 2023-11-14 (ts ≈ 1_700_000_000).
// 2 messages in group@g.us on a different day (ts ≈ 1_700_100_000 =
// 2023-11-15 UTC).
let day1_ts = 1_700_000_000i64; // 2023-11-14
let day2_ts = 1_700_100_000i64; // 2023-11-15
let messages = vec![
make_msg("alice@c.us", day1_ts, "Hello", false),
make_msg("alice@c.us", day1_ts + 60, "How are you?", false),
make_msg("alice@c.us", day1_ts + 120, "Fine thanks", true),
make_msg("group@g.us", day2_ts, "Meeting at 3pm", false),
make_msg("group@g.us", day2_ts + 30, "Got it", true),
];
// Collect groups the same way emit_grouped_whatsapp does it.
let empty_chats = serde_json::Map::new();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut groups: HashMap<(String, String), Vec<Value>> = HashMap::new();
for m in &messages {
let chat_id = match m.get("chatId").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
let body = m
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.unwrap_or_default();
if body.is_empty() {
continue;
}
let day: String = if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) {
seconds_to_ymd(t)
} else {
seconds_to_ymd(now_secs)
};
let _ = &empty_chats;
groups.entry((chat_id, day)).or_default().push(m.clone());
}
assert_eq!(groups.len(), 2, "expected exactly 2 (chatId, day) groups");
let alice_day = seconds_to_ymd(day1_ts);
let group_day = seconds_to_ymd(day2_ts);
let alice_key = ("alice@c.us".to_string(), alice_day.clone());
let group_key = ("group@g.us".to_string(), group_day.clone());
assert!(
groups.contains_key(&alice_key),
"alice group missing; groups: {groups:?}"
);
assert!(
groups.contains_key(&group_key),
"group@g.us group missing; groups: {groups:?}"
);
assert_eq!(
groups[&alice_key].len(),
3,
"alice chat should have 3 messages"
);
assert_eq!(
groups[&group_key].len(),
2,
"group chat should have 2 messages"
);
}
// ── transcript format ─────────────────────────────────────────────────────
#[test]
fn build_doc_ingest_params_transcript_contains_senders_and_bodies() {
let day_ts = 1_700_000_000i64; // 2023-11-14
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": seconds_to_ymd(day_ts),
"messages": [
{
"chatId": "alice@c.us",
"fromMe": false,
"from": "alice@c.us",
"fromName": "Alice",
"body": "Hey there!",
"timestamp": day_ts,
},
{
"chatId": "alice@c.us",
"fromMe": true,
"from": "me",
"fromName": null,
"body": "Hi Alice!",
"timestamp": day_ts + 60,
},
{
"chatId": "alice@c.us",
"fromMe": false,
"from": "alice@c.us",
"fromName": "Alice",
"body": "How are you?",
"timestamp": day_ts + 120,
},
],
});
let params = build_doc_ingest_params("test-acct@c.us", &ingest)
.expect("should build params for valid ingest");
let content = params
.get("content")
.and_then(|v| v.as_str())
.expect("content must be present");
// Senders should appear in the transcript.
assert!(
content.contains("Alice"),
"transcript must contain sender name 'Alice'; content:\n{content}"
);
assert!(
content.contains("me"),
"transcript must contain 'me' for self-sent messages; content:\n{content}"
);
// Bodies must be present.
assert!(
content.contains("Hey there!"),
"transcript must contain first message body; content:\n{content}"
);
assert!(
content.contains("Hi Alice!"),
"transcript must contain second message body; content:\n{content}"
);
assert!(
content.contains("How are you?"),
"transcript must contain third message body; content:\n{content}"
);
// Lines must appear in ascending timestamp order — verify by position.
let pos_hey = content.find("Hey there!").expect("Hey there not found");
let pos_hi = content.find("Hi Alice!").expect("Hi Alice not found");
let pos_how = content.find("How are you?").expect("How are you not found");
assert!(
pos_hey < pos_hi && pos_hi < pos_how,
"transcript lines must be in timestamp order"
);
}
// ── build_doc_ingest_params payload shape ─────────────────────────────────
#[test]
fn build_doc_ingest_params_namespace_and_key_format() {
let day = "2023-11-14";
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": day,
"messages": [
{ "chatId": "alice@c.us", "fromMe": false, "from": "alice@c.us",
"fromName": "Alice", "body": "Hello", "timestamp": 1_700_000_000i64 }
],
});
let params =
build_doc_ingest_params("test-acct@c.us", &ingest).expect("should build params");
assert_eq!(
params.get("namespace").and_then(|v| v.as_str()),
Some("whatsapp-web:test-acct@c.us"),
"namespace must be 'whatsapp-web:<account_id>'"
);
assert_eq!(
params.get("key").and_then(|v| v.as_str()),
Some("alice@c.us:2023-11-14"),
"key must be '<chat_id>:<day>'"
);
assert_eq!(
params.get("source_type").and_then(|v| v.as_str()),
Some("whatsapp-web"),
"source_type must be 'whatsapp-web'"
);
// Content must be non-empty and contain the body.
let content = params
.get("content")
.and_then(|v| v.as_str())
.expect("content must be present");
assert!(!content.is_empty(), "content must not be empty");
assert!(
content.contains("Hello"),
"content must contain message body; got:\n{content}"
);
}
#[test]
fn build_doc_ingest_params_missing_chat_id_returns_none() {
let ingest = json!({
"chatName": "Alice",
"day": "2023-11-14",
"messages": [
{ "chatId": "alice@c.us", "fromMe": false, "body": "Hello", "timestamp": 1i64 }
],
});
assert!(
build_doc_ingest_params("acct", &ingest).is_none(),
"missing chatId must return None"
);
}
#[test]
fn build_doc_ingest_params_empty_messages_returns_none() {
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": "2023-11-14",
"messages": [],
});
assert!(
build_doc_ingest_params("acct", &ingest).is_none(),
"empty messages must return None"
);
}
// ── DOM-IDB merge ─────────────────────────────────────────────────────────
#[test]
fn merge_dom_patches_empty_body_from_idb_message() {
// IDB message with empty body; matching DOM row has the decrypted body.
let idb = vec![json!({
"id": "abc123",
"chatId": "alice@c.us",
"fromMe": false,
"body": "",
})];
let dom = vec![json!({
"dataId": "abc123",
"msgId": "abc123",
"chatId": "alice@c.us",
"fromMe": false,
"body": "Hello",
"author": "Alice",
"preTimestamp": null,
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 1, "one message should be patched");
assert_eq!(appended, 0, "no messages should be appended");
assert_eq!(merged.len(), 1, "still one message in merged list");
let body = merged[0]
.get("body")
.and_then(|v| v.as_str())
.expect("body must be present");
assert_eq!(body, "Hello", "patched body must equal DOM body");
let source = merged[0]
.get("bodySource")
.and_then(|v| v.as_str())
.expect("bodySource must be present");
assert_eq!(source, "dom", "bodySource must be 'dom' after patching");
}
#[test]
fn merge_dom_appends_unmatched_row_with_active_chat_backfill() {
// No IDB messages; DOM has a row with no chatId. active_chat_jid
// should be stamped onto the appended message.
let idb: Vec<Value> = vec![];
let dom = vec![json!({
"dataId": "newrow1",
"msgId": "newrow1",
"chatId": "", // empty — needs backfill
"fromMe": false,
"body": "Hey from active chat",
"author": "Bob",
"preTimestamp": null,
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, Some("bob@c.us"));
assert_eq!(patched, 0, "nothing to patch");
assert_eq!(appended, 1, "one row should be appended");
assert_eq!(merged.len(), 1, "merged list should have 1 entry");
let chat_id = merged[0]
.get("chatId")
.and_then(|v| v.as_str())
.expect("chatId must be present");
assert_eq!(
chat_id, "bob@c.us",
"chatId should be backfilled from active_chat_jid"
);
let body_source = merged[0]
.get("bodySource")
.and_then(|v| v.as_str())
.expect("bodySource must be present");
assert_eq!(body_source, "dom-only");
}
#[test]
fn merge_dom_does_not_append_row_without_body() {
// DOM rows without a body should be silently skipped.
let idb: Vec<Value> = vec![];
let dom = vec![json!({
"dataId": "empty1",
"msgId": "empty1",
"chatId": "alice@c.us",
"fromMe": false,
"body": "",
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 0);
assert_eq!(appended, 0, "empty-body DOM rows must not be appended");
assert!(
merged.is_empty(),
"no messages should appear in merged list"
);
}
#[test]
fn merge_dom_does_not_consume_row_twice() {
// Two IDB messages with the same bare msgId; only the first match
// should consume the DOM row.
let idb = vec![
json!({ "id": "chat_abc", "chatId": "alice@c.us", "fromMe": false, "body": "" }),
json!({ "id": "chat_abc_2", "chatId": "alice@c.us", "fromMe": true, "body": "" }),
];
// DOM row keyed only by bare msgId "abc".
let dom = vec![json!({
"dataId": "abc",
"msgId": "abc",
"chatId": "alice@c.us",
"fromMe": false,
"body": "Only once",
})];
let (merged, patched, _appended) = merge_dom_into_snapshot(&idb, &dom, None);
// Exactly one of the two IDB messages should be patched.
assert_eq!(patched, 1, "DOM row must be consumed at most once");
assert_eq!(merged.len(), 2, "both IDB messages must survive merge");
let patched_bodies: Vec<&str> = merged
.iter()
.filter_map(|m| m.get("body").and_then(|v| v.as_str()))
.filter(|b| *b == "Only once")
.collect();
assert_eq!(
patched_bodies.len(),
1,
"body 'Only once' must appear exactly once in merged list"
);
}
#[test]
fn merge_dom_empty_dom_returns_idb_messages_unchanged() {
let idb = vec![
json!({ "id": "m1", "chatId": "a@c.us", "body": "hello" }),
json!({ "id": "m2", "chatId": "a@c.us", "body": "" }),
];
let dom: Vec<Value> = vec![];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 0);
assert_eq!(appended, 0);
assert_eq!(merged.len(), 2, "IDB messages must be returned unchanged");
assert_eq!(
merged[0].get("body").and_then(|v| v.as_str()),
Some("hello")
);
}
}
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,609 @@
use super::*;
// ── Issue #1376 — chat-name normalization for active-chat → JID lookup ──
#[test]
fn normalize_chat_name_strips_punctuation_and_emoji() {
// Group titles routinely pick up emoji + punctuation drift between
// the DOM-parsed conversation header and the IDB-stored chat name.
// Normalization should collapse both sides to the same key so the
// lookup at scan_once succeeds.
assert_eq!(
normalize_chat_name("17-18-19 July samagam"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17 18 19 July samagam"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17-18-19 July samagam ✨"),
"171819julysamagam"
);
assert_eq!(
normalize_chat_name("17.18.19 July, samagam!"),
"171819julysamagam"
);
// Identity property — already-normal strings round-trip unchanged.
assert_eq!(normalize_chat_name("foo123"), "foo123");
// Empty input → empty output (caller guards against this).
assert_eq!(normalize_chat_name(""), "");
assert_eq!(normalize_chat_name(" "), "");
assert_eq!(normalize_chat_name(""), "");
}
#[test]
fn normalize_chat_name_lowercases() {
assert_eq!(normalize_chat_name("Hello World"), "helloworld");
assert_eq!(normalize_chat_name("HELLO"), "hello");
assert_eq!(normalize_chat_name("hElLo"), "hello");
}
fn insert_pending_tasks(
registry: &ScannerRegistry,
account_id: &str,
count: usize,
) -> Vec<tokio::task::JoinHandle<()>> {
let mut tasks = Vec::with_capacity(count);
let mut abort_handles = Vec::with_capacity(count);
for _ in 0..count {
let task = tokio::spawn(async {
std::future::pending::<()>().await;
});
abort_handles.push(task.abort_handle());
tasks.push(task);
}
registry
.started
.lock()
.insert(account_id.to_string(), abort_handles);
tasks
}
async fn assert_cancelled(task: tokio::task::JoinHandle<()>) {
let err = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("aborted scanner task should finish")
.expect_err("scanner task should be cancelled");
assert!(err.is_cancelled());
}
async fn assert_all_cancelled(tasks: Vec<tokio::task::JoinHandle<()>>) {
for task in tasks {
assert_cancelled(task).await;
}
}
#[tokio::test]
async fn registry_forget_aborts_all_handles_for_account_only() {
let registry = ScannerRegistry::default();
let account_tasks = insert_pending_tasks(&registry, "acct-1", 2);
let survivor_tasks = insert_pending_tasks(&registry, "acct-2", 1);
registry.forget("acct-1");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-2"));
}
assert_all_cancelled(account_tasks).await;
assert!(
!survivor_tasks[0].is_finished(),
"forget(acct-1) must not abort acct-2"
);
assert_eq!(registry.forget_all(), 1);
assert_all_cancelled(survivor_tasks).await;
}
#[tokio::test]
async fn registry_forget_missing_account_is_noop() {
let registry = ScannerRegistry::default();
let mut tasks = insert_pending_tasks(&registry, "acct-1", 1);
registry.forget("missing");
{
let guard = registry.started.lock();
assert_eq!(guard.len(), 1);
assert!(guard.contains_key("acct-1"));
}
assert!(
!tasks[0].is_finished(),
"forget(missing) must not abort existing scanners"
);
registry.forget("acct-1");
assert_cancelled(tasks.pop().expect("task")).await;
}
#[tokio::test]
async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() {
let registry = ScannerRegistry::default();
let task_a = insert_pending_tasks(&registry, "acct-1", 2);
let task_b = insert_pending_tasks(&registry, "acct-2", 3);
assert_eq!(registry.forget_all(), 5);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(task_a).await;
assert_all_cancelled(task_b).await;
}
#[tokio::test]
async fn registry_forget_all_is_repeatable_noop_after_drain() {
let registry = ScannerRegistry::default();
assert_eq!(registry.forget_all(), 0);
let tasks = insert_pending_tasks(&registry, "acct-1", 1);
assert_eq!(registry.forget_all(), 1);
assert_eq!(registry.forget_all(), 0);
assert!(registry.started.lock().is_empty());
assert_all_cancelled(tasks).await;
}
// ── seconds_to_ymd ────────────────────────────────────────────────────────
#[test]
fn seconds_to_ymd_known_timestamp() {
// Unix timestamp 1_700_000_000 = 2023-11-14 (UTC).
assert_eq!(seconds_to_ymd(1_700_000_000), "2023-11-14");
}
#[test]
fn seconds_to_ymd_epoch_zero() {
// Unix epoch origin = 1970-01-01.
assert_eq!(seconds_to_ymd(0), "1970-01-01");
}
#[test]
fn seconds_to_ymd_output_format_is_yyyy_mm_dd() {
let s = seconds_to_ymd(1_700_000_000);
// Must match YYYY-MM-DD: 10 chars, digit/digit/digit/digit-...-...
assert_eq!(s.len(), 10, "expected 10-char date string, got: {s}");
let parts: Vec<&str> = s.split('-').collect();
assert_eq!(parts.len(), 3, "expected 3 dash-separated parts: {s}");
assert_eq!(parts[0].len(), 4, "year must be 4 digits: {s}");
assert_eq!(parts[1].len(), 2, "month must be 2 digits: {s}");
assert_eq!(parts[2].len(), 2, "day must be 2 digits: {s}");
assert!(
parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())),
"all parts must be numeric: {s}"
);
}
// ── parse_pre_timestamp_ymd ───────────────────────────────────────────────
#[test]
fn parse_pre_timestamp_ymd_valid_wa_format() {
// WhatsApp Web format: "4:53 AM, 7/5/2025"
let result = parse_pre_timestamp_ymd("4:53 AM, 7/5/2025");
assert_eq!(result.as_deref(), Some("2025-07-05"));
}
#[test]
fn parse_pre_timestamp_ymd_another_valid_date() {
// "10:01 PM, 11/14/2023" — matches our known ts
let result = parse_pre_timestamp_ymd("10:01 PM, 11/14/2023");
assert_eq!(result.as_deref(), Some("2023-11-14"));
}
#[test]
fn parse_pre_timestamp_ymd_empty_string_returns_none() {
assert!(parse_pre_timestamp_ymd("").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_no_comma_returns_none() {
assert!(parse_pre_timestamp_ymd("4:53 AM 7/5/2025").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_invalid_date_parts_return_none() {
// Month 13 is out of range.
assert!(parse_pre_timestamp_ymd("10:00 AM, 13/5/2025").is_none());
// Day 32 is out of range.
assert!(parse_pre_timestamp_ymd("10:00 AM, 1/32/2025").is_none());
}
#[test]
fn parse_pre_timestamp_ymd_garbage_returns_none() {
assert!(parse_pre_timestamp_ymd("not a timestamp at all").is_none());
}
// ── emit_grouped_whatsapp grouping ────────────────────────────────────────
/// Build a minimal message Value that `emit_grouped_whatsapp` will accept.
fn make_msg(chat_id: &str, ts: i64, body: &str, from_me: bool) -> Value {
json!({
"chatId": chat_id,
"body": body,
"timestamp": ts,
"fromMe": from_me,
"from": if from_me { "me" } else { chat_id },
})
}
#[test]
fn grouping_produces_correct_group_count_and_keys() {
use std::collections::HashMap;
// 3 messages in alice@c.us on day 2023-11-14 (ts ≈ 1_700_000_000).
// 2 messages in group@g.us on a different day (ts ≈ 1_700_100_000 =
// 2023-11-15 UTC).
let day1_ts = 1_700_000_000i64; // 2023-11-14
let day2_ts = 1_700_100_000i64; // 2023-11-15
let messages = vec![
make_msg("alice@c.us", day1_ts, "Hello", false),
make_msg("alice@c.us", day1_ts + 60, "How are you?", false),
make_msg("alice@c.us", day1_ts + 120, "Fine thanks", true),
make_msg("group@g.us", day2_ts, "Meeting at 3pm", false),
make_msg("group@g.us", day2_ts + 30, "Got it", true),
];
// Collect groups the same way emit_grouped_whatsapp does it.
let empty_chats = serde_json::Map::new();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut groups: HashMap<(String, String), Vec<Value>> = HashMap::new();
for m in &messages {
let chat_id = match m.get("chatId").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
let body = m
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.unwrap_or_default();
if body.is_empty() {
continue;
}
let day: String = if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) {
seconds_to_ymd(t)
} else {
seconds_to_ymd(now_secs)
};
let _ = &empty_chats;
groups.entry((chat_id, day)).or_default().push(m.clone());
}
assert_eq!(groups.len(), 2, "expected exactly 2 (chatId, day) groups");
let alice_day = seconds_to_ymd(day1_ts);
let group_day = seconds_to_ymd(day2_ts);
let alice_key = ("alice@c.us".to_string(), alice_day.clone());
let group_key = ("group@g.us".to_string(), group_day.clone());
assert!(
groups.contains_key(&alice_key),
"alice group missing; groups: {groups:?}"
);
assert!(
groups.contains_key(&group_key),
"group@g.us group missing; groups: {groups:?}"
);
assert_eq!(
groups[&alice_key].len(),
3,
"alice chat should have 3 messages"
);
assert_eq!(
groups[&group_key].len(),
2,
"group chat should have 2 messages"
);
}
// ── transcript format ─────────────────────────────────────────────────────
#[test]
fn build_doc_ingest_params_transcript_contains_senders_and_bodies() {
let day_ts = 1_700_000_000i64; // 2023-11-14
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": seconds_to_ymd(day_ts),
"messages": [
{
"chatId": "alice@c.us",
"fromMe": false,
"from": "alice@c.us",
"fromName": "Alice",
"body": "Hey there!",
"timestamp": day_ts,
},
{
"chatId": "alice@c.us",
"fromMe": true,
"from": "me",
"fromName": null,
"body": "Hi Alice!",
"timestamp": day_ts + 60,
},
{
"chatId": "alice@c.us",
"fromMe": false,
"from": "alice@c.us",
"fromName": "Alice",
"body": "How are you?",
"timestamp": day_ts + 120,
},
],
});
let params = build_doc_ingest_params("test-acct@c.us", &ingest)
.expect("should build params for valid ingest");
let content = params
.get("content")
.and_then(|v| v.as_str())
.expect("content must be present");
// Senders should appear in the transcript.
assert!(
content.contains("Alice"),
"transcript must contain sender name 'Alice'; content:\n{content}"
);
assert!(
content.contains("me"),
"transcript must contain 'me' for self-sent messages; content:\n{content}"
);
// Bodies must be present.
assert!(
content.contains("Hey there!"),
"transcript must contain first message body; content:\n{content}"
);
assert!(
content.contains("Hi Alice!"),
"transcript must contain second message body; content:\n{content}"
);
assert!(
content.contains("How are you?"),
"transcript must contain third message body; content:\n{content}"
);
// Lines must appear in ascending timestamp order — verify by position.
let pos_hey = content.find("Hey there!").expect("Hey there not found");
let pos_hi = content.find("Hi Alice!").expect("Hi Alice not found");
let pos_how = content.find("How are you?").expect("How are you not found");
assert!(
pos_hey < pos_hi && pos_hi < pos_how,
"transcript lines must be in timestamp order"
);
}
// ── build_doc_ingest_params payload shape ─────────────────────────────────
#[test]
fn build_doc_ingest_params_namespace_and_key_format() {
let day = "2023-11-14";
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": day,
"messages": [
{ "chatId": "alice@c.us", "fromMe": false, "from": "alice@c.us",
"fromName": "Alice", "body": "Hello", "timestamp": 1_700_000_000i64 }
],
});
let params = build_doc_ingest_params("test-acct@c.us", &ingest).expect("should build params");
assert_eq!(
params.get("namespace").and_then(|v| v.as_str()),
Some("whatsapp-web:test-acct@c.us"),
"namespace must be 'whatsapp-web:<account_id>'"
);
assert_eq!(
params.get("key").and_then(|v| v.as_str()),
Some("alice@c.us:2023-11-14"),
"key must be '<chat_id>:<day>'"
);
assert_eq!(
params.get("source_type").and_then(|v| v.as_str()),
Some("whatsapp-web"),
"source_type must be 'whatsapp-web'"
);
// Content must be non-empty and contain the body.
let content = params
.get("content")
.and_then(|v| v.as_str())
.expect("content must be present");
assert!(!content.is_empty(), "content must not be empty");
assert!(
content.contains("Hello"),
"content must contain message body; got:\n{content}"
);
}
#[test]
fn build_doc_ingest_params_missing_chat_id_returns_none() {
let ingest = json!({
"chatName": "Alice",
"day": "2023-11-14",
"messages": [
{ "chatId": "alice@c.us", "fromMe": false, "body": "Hello", "timestamp": 1i64 }
],
});
assert!(
build_doc_ingest_params("acct", &ingest).is_none(),
"missing chatId must return None"
);
}
#[test]
fn build_doc_ingest_params_empty_messages_returns_none() {
let ingest = json!({
"chatId": "alice@c.us",
"chatName": "Alice",
"day": "2023-11-14",
"messages": [],
});
assert!(
build_doc_ingest_params("acct", &ingest).is_none(),
"empty messages must return None"
);
}
// ── DOM-IDB merge ─────────────────────────────────────────────────────────
#[test]
fn merge_dom_patches_empty_body_from_idb_message() {
// IDB message with empty body; matching DOM row has the decrypted body.
let idb = vec![json!({
"id": "abc123",
"chatId": "alice@c.us",
"fromMe": false,
"body": "",
})];
let dom = vec![json!({
"dataId": "abc123",
"msgId": "abc123",
"chatId": "alice@c.us",
"fromMe": false,
"body": "Hello",
"author": "Alice",
"preTimestamp": null,
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 1, "one message should be patched");
assert_eq!(appended, 0, "no messages should be appended");
assert_eq!(merged.len(), 1, "still one message in merged list");
let body = merged[0]
.get("body")
.and_then(|v| v.as_str())
.expect("body must be present");
assert_eq!(body, "Hello", "patched body must equal DOM body");
let source = merged[0]
.get("bodySource")
.and_then(|v| v.as_str())
.expect("bodySource must be present");
assert_eq!(source, "dom", "bodySource must be 'dom' after patching");
}
#[test]
fn merge_dom_appends_unmatched_row_with_active_chat_backfill() {
// No IDB messages; DOM has a row with no chatId. active_chat_jid
// should be stamped onto the appended message.
let idb: Vec<Value> = vec![];
let dom = vec![json!({
"dataId": "newrow1",
"msgId": "newrow1",
"chatId": "", // empty — needs backfill
"fromMe": false,
"body": "Hey from active chat",
"author": "Bob",
"preTimestamp": null,
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, Some("bob@c.us"));
assert_eq!(patched, 0, "nothing to patch");
assert_eq!(appended, 1, "one row should be appended");
assert_eq!(merged.len(), 1, "merged list should have 1 entry");
let chat_id = merged[0]
.get("chatId")
.and_then(|v| v.as_str())
.expect("chatId must be present");
assert_eq!(
chat_id, "bob@c.us",
"chatId should be backfilled from active_chat_jid"
);
let body_source = merged[0]
.get("bodySource")
.and_then(|v| v.as_str())
.expect("bodySource must be present");
assert_eq!(body_source, "dom-only");
}
#[test]
fn merge_dom_does_not_append_row_without_body() {
// DOM rows without a body should be silently skipped.
let idb: Vec<Value> = vec![];
let dom = vec![json!({
"dataId": "empty1",
"msgId": "empty1",
"chatId": "alice@c.us",
"fromMe": false,
"body": "",
})];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 0);
assert_eq!(appended, 0, "empty-body DOM rows must not be appended");
assert!(
merged.is_empty(),
"no messages should appear in merged list"
);
}
#[test]
fn merge_dom_does_not_consume_row_twice() {
// Two IDB messages with the same bare msgId; only the first match
// should consume the DOM row.
let idb = vec![
json!({ "id": "chat_abc", "chatId": "alice@c.us", "fromMe": false, "body": "" }),
json!({ "id": "chat_abc_2", "chatId": "alice@c.us", "fromMe": true, "body": "" }),
];
// DOM row keyed only by bare msgId "abc".
let dom = vec![json!({
"dataId": "abc",
"msgId": "abc",
"chatId": "alice@c.us",
"fromMe": false,
"body": "Only once",
})];
let (merged, patched, _appended) = merge_dom_into_snapshot(&idb, &dom, None);
// Exactly one of the two IDB messages should be patched.
assert_eq!(patched, 1, "DOM row must be consumed at most once");
assert_eq!(merged.len(), 2, "both IDB messages must survive merge");
let patched_bodies: Vec<&str> = merged
.iter()
.filter_map(|m| m.get("body").and_then(|v| v.as_str()))
.filter(|b| *b == "Only once")
.collect();
assert_eq!(
patched_bodies.len(),
1,
"body 'Only once' must appear exactly once in merged list"
);
}
#[test]
fn merge_dom_empty_dom_returns_idb_messages_unchanged() {
let idb = vec![
json!({ "id": "m1", "chatId": "a@c.us", "body": "hello" }),
json!({ "id": "m2", "chatId": "a@c.us", "body": "" }),
];
let dom: Vec<Value> = vec![];
let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None);
assert_eq!(patched, 0);
assert_eq!(appended, 0);
assert_eq!(merged.len(), 2, "IDB messages must be returned unchanged");
assert_eq!(
merged[0].get("body").and_then(|v| v.as_str()),
Some("hello")
);
}
+2 -3556
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -60,7 +60,7 @@ mod bughunt_tests;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
mod test_support_test;
mod test_support_tests;
#[cfg(test)]
mod harness_gap_tests;
@@ -487,5 +487,5 @@ fn with_ownership_boundary(prompt: &str, ownership: Option<&str>) -> String {
}
#[cfg(test)]
#[path = "spawn_parallel_agents_test.rs"]
#[path = "spawn_parallel_agents_tests.rs"]
mod tests;
+1 -1
View File
@@ -1121,7 +1121,7 @@ fn store_get_clear_composio_api_key_roundtrip() {
// 400-class user error.
// * `direct_execute` accepts a None-arguments call and falls
// through to the underlying tool surface (which then errors on the
// network call — covered by the integration test in `ops_test.rs`).
// network call — covered by the integration test in `ops_tests.rs`).
// * `direct_list_connections` is a thin mapper; the real coverage
// for its row → ComposioConnection translation lives in the
// `connected_account_*` tests in `composio_tests.rs`.
+1 -1
View File
@@ -2371,7 +2371,7 @@ pub async fn composio_clear_api_key(config: &Config) -> OpResult<RpcOutcome<serd
}
#[cfg(test)]
#[path = "ops_test.rs"]
#[path = "ops_tests.rs"]
mod tests;
// ── Helpers re-exported so callers can pull connection/tool types without
+1 -1
View File
@@ -426,5 +426,5 @@ fn generate_id() -> String {
}
#[cfg(test)]
#[path = "registry_test.rs"]
#[path = "registry_tests.rs"]
mod tests;
@@ -1,7 +1,7 @@
//! Unit tests for [`super::JailRegistry`].
//!
//! Lives next to `registry.rs` (wired in via `#[cfg(test)] #[path =
//! "registry_test.rs"] mod tests;`) so the production module stays under
//! "registry_tests.rs"] mod tests;`) so the production module stays under
//! the ~500-line guideline.
use super::*;
+2 -2
View File
@@ -1088,5 +1088,5 @@ fn redact_endpoint(url: &str) -> String {
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "factory_test.rs"]
mod factory_test;
#[path = "factory_tests.rs"]
mod factory_tests;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -244,5 +244,5 @@ impl Provider for RouterProvider {
}
#[cfg(test)]
#[path = "router_test.rs"]
mod router_test;
#[path = "router_tests.rs"]
mod router_tests;
+1 -1
View File
@@ -793,5 +793,5 @@ pub async fn spawn_fake_integration_backend() -> FakeIntegrationBackend {
}
#[cfg(test)]
#[path = "test_support_test.rs"]
#[path = "test_support_tests.rs"]
mod tests;
+2 -912
View File
@@ -1416,915 +1416,5 @@ fn json_type_name(value: &Value) -> &'static str {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() {
let config = crate::openhuman::config::Config::default();
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk",
"tree.browse",
"tree.top_entities",
"tree.list_sources",
"memory.store",
"memory.note",
"tree.tag",
]
);
}
#[test]
fn list_tools_emits_annotations_for_every_tool() {
// Exercise the searxng-enabled config so the annotation contract covers
// every shipping tool, not just the base set.
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let tools = result["tools"].as_array().expect("tools array");
for tool in tools {
let name = tool["name"].as_str().expect("tool name");
assert!(
tool.get("annotations")
.map(Value::is_object)
.unwrap_or(false),
"tool `{name}` is missing a serialized `annotations` object",
);
}
}
#[test]
fn read_only_tools_are_marked_read_only_and_closed_world() {
// Every tool except the act-capable ones reads local OpenHuman state
// (memory tree / agent registry) or queries an external read-only
// search engine. Per MCP spec defaults these would be
// `readOnlyHint: false` and `openWorldHint: true`, so we MUST set
// `readOnlyHint` explicitly to communicate accurate safety affordances
// to clients. (`searxng_search` is read-only but openWorld, so it
// verifies the read-only axis here and is exempt from the
// openWorld=false check below.)
let act_tool_names = [
"agent.run_subagent",
"memory.store",
"memory.note",
"tree.tag",
];
let open_world_read_only = ["searxng_search"];
for spec in tool_specs() {
if act_tool_names.contains(&spec.name) {
continue;
}
let annotations = &spec.annotations;
assert_eq!(
annotations.get("readOnlyHint").and_then(Value::as_bool),
Some(true),
"expected `{}` to advertise readOnlyHint=true",
spec.name
);
let expected_open_world = open_world_read_only.contains(&spec.name);
assert_eq!(
annotations.get("openWorldHint").and_then(Value::as_bool),
Some(expected_open_world),
"expected `{}` to advertise openWorldHint={}",
spec.name,
expected_open_world
);
// Per spec these are meaningful only when readOnlyHint == false.
// Emitting them on a read-only tool would be misleading.
assert!(
annotations.get("destructiveHint").is_none(),
"read-only tool `{}` should not emit destructiveHint",
spec.name
);
assert!(
annotations.get("idempotentHint").is_none(),
"read-only tool `{}` should not emit idempotentHint",
spec.name
);
}
}
#[test]
fn run_subagent_annotations_signal_act_semantics() {
let spec = tool_specs()
.into_iter()
.find(|spec| spec.name == "agent.run_subagent")
.expect("agent.run_subagent must be registered");
assert_eq!(
spec.annotations
.get("readOnlyHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("destructiveHint")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
spec.annotations
.get("idempotentHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("openWorldHint")
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn list_tools_includes_searxng_when_enabled() {
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert!(names.contains(&"searxng_search"));
}
#[test]
fn mapped_rpc_methods_are_registered() {
for spec in tool_specs() {
if let Some(rpc_method) = spec.rpc_method {
assert!(
all::schema_for_rpc_method(rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
rpc_method
);
}
}
}
#[test]
fn build_rpc_params_parses_run_subagent_arguments() {
let params = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause."
}),
)
.expect("params should parse");
assert_eq!(
params.get("agent_id").and_then(Value::as_str),
Some("researcher")
);
assert_eq!(
params.get("prompt").and_then(Value::as_str),
Some("Find the root cause.")
);
}
#[test]
fn build_rpc_params_rejects_extra_run_subagent_fields() {
let err = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause.",
"toolkit": "gmail"
}),
)
.expect_err("unexpected field should be rejected");
assert!(
matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument"))
);
}
#[test]
fn memory_search_params_trim_query_and_use_default_k() {
let params = build_rpc_params(
"memory.search",
json!({
"query": " phoenix migration ",
}),
)
.expect("params");
assert_eq!(params["query"], "phoenix migration");
assert_eq!(params["k"], DEFAULT_LIMIT);
}
#[test]
fn searxng_search_params_accept_optional_fields() {
let params = build_rpc_params(
"searxng_search",
json!({
"query": " rust async ",
"categories": ["web", "news"],
"language": " en ",
"max_results": 12
}),
)
.expect("params");
assert_eq!(params["query"], "rust async");
assert_eq!(params["categories"], json!(["web", "news"]));
assert_eq!(params["language"], "en");
assert_eq!(params["max_results"], 12);
}
#[test]
fn searxng_search_rejects_unknown_category() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"categories": ["videos"]
}),
)
.expect_err("must reject");
assert!(err.message().contains("unsupported SearXNG category"));
}
#[test]
fn searxng_search_rejects_max_results_above_max() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"max_results": SEARXNG_MAX_RESULTS + 1
}),
)
.expect_err("must reject");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn memory_search_rejects_k_above_max() {
// Reject (don't silent-clamp) so the LLM can self-correct on the next
// call. Silent clamping makes the model believe it got the page size
// it asked for and prevents the corrective feedback loop.
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"k": MAX_LIMIT + 1
}),
)
.expect_err("must reject k > MAX_LIMIT");
let message = err.message();
assert!(
message.contains("must not exceed"),
"error should mention the cap, got: {message}"
);
assert!(
message.contains(&MAX_LIMIT.to_string()),
"error should mention the limit value, got: {message}"
);
}
#[test]
fn memory_search_accepts_k_at_max() {
let params = build_rpc_params(
"memory.search",
json!({ "query": "phoenix", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT must be accepted (boundary inclusive)");
assert_eq!(params["k"], MAX_LIMIT);
}
#[test]
fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() {
let err = ToolCallError::InvalidParams("missing query".to_string());
assert_eq!(err.code(), -32602);
assert_eq!(err.jsonrpc_message(), "Invalid params");
assert_eq!(err.message(), "missing query");
}
#[test]
fn tool_call_error_internal_maps_to_jsonrpc_internal_error() {
// Server-side failures (config load, missing resources) must surface
// as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP
// client doesn't mislead the user / LLM into retrying with different
// arguments.
let err = ToolCallError::Internal("disk read failed".to_string());
assert_eq!(err.code(), -32603);
assert_eq!(err.jsonrpc_message(), "Internal error");
assert_eq!(err.message(), "disk read failed");
}
#[test]
fn memory_recall_requires_query() {
let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `query`"));
}
#[test]
fn memory_search_rejects_undocumented_limit_alias() {
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"limit": 5
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_read_chunk_maps_chunk_id_to_controller_id() {
let params =
build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params");
assert_eq!(params["id"], "abc");
assert!(!params.contains_key("chunk_id"));
}
#[test]
fn tree_read_chunk_rejects_unknown_arguments() {
let err = build_rpc_params(
"tree.read_chunk",
json!({
"chunk_id": "abc",
"unused": true
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `unused`"));
}
#[test]
fn non_object_arguments_are_invalid() {
let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject");
assert!(err.message().contains("arguments must be an object"));
}
// ── tree.browse ────────────────────────────────────────────────────
#[test]
fn tree_browse_no_args_sends_default_limit_only() {
// Empty filter is a valid request — the controller treats unset filters
// as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT
// so the LLM doesn't accidentally pull the controller's 50-row default
// when it asked for nothing.
let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid");
assert_eq!(params.len(), 1);
assert_eq!(params["limit"], DEFAULT_LIMIT);
}
#[test]
fn tree_browse_passes_through_filters_and_renames_k_to_limit() {
let params = build_rpc_params(
"tree.browse",
json!({
"source_kinds": ["email", "chat"],
"source_ids": ["acme-thread-1"],
"entity_ids": ["person:Alice"],
"since_ms": 1_700_000_000_000_i64,
"until_ms": 1_710_000_000_000_i64,
"query": "Q3 plan",
"k": 20,
"offset": 10
}),
)
.expect("params");
assert_eq!(params["limit"], 20);
assert!(!params.contains_key("k"));
assert_eq!(params["source_kinds"], json!(["email", "chat"]));
assert_eq!(params["source_ids"], json!(["acme-thread-1"]));
assert_eq!(params["entity_ids"], json!(["person:Alice"]));
assert_eq!(params["since_ms"], 1_700_000_000_000_i64);
assert_eq!(params["until_ms"], 1_710_000_000_000_i64);
assert_eq!(params["query"], "Q3 plan");
assert_eq!(params["offset"], 10);
}
#[test]
fn tree_browse_rejects_k_above_max() {
// Same reject-don't-clamp policy as memory.search / memory.recall so the
// LLM gets corrective feedback instead of silently receiving fewer rows
// than it asked for.
let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 }))
.expect_err("must reject k > MAX_LIMIT");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn tree_browse_rejects_unknown_argument() {
let err = build_rpc_params("tree.browse", json!({ "limit": 10 }))
.expect_err("must reject the controller's `limit` alias");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_browse_rejects_non_array_source_kinds() {
let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" }))
.expect_err("must reject scalar where array is required");
assert!(err.message().contains("must be an array of strings"));
}
#[test]
fn tree_browse_rejects_non_integer_since_ms() {
let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" }))
.expect_err("must reject ISO-style date for ms field");
assert!(err.message().contains("must be an integer"));
}
#[test]
fn tree_browse_drops_blank_array_entries_silently() {
// Empty / whitespace strings inside an array are tolerated — clients
// sometimes send `["", "email"]` after a partial UI selection and the
// intent ("filter to email") is unambiguous. A fully-blank array is OK
// too and produces an empty filter (same as omitting the field).
let params = build_rpc_params(
"tree.browse",
json!({ "source_kinds": ["", "email", " "] }),
)
.expect("blank entries don't fail the whole call");
assert_eq!(params["source_kinds"], json!(["email"]));
}
// ── tree.top_entities ──────────────────────────────────────────────
#[test]
fn tree_top_entities_defaults_limit_and_omits_kind() {
let params =
build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid");
assert_eq!(params["limit"], DEFAULT_LIMIT);
assert!(!params.contains_key("kind"));
}
#[test]
fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() {
let params = build_rpc_params(
"tree.top_entities",
json!({ "kind": "person", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT is the boundary, inclusive");
assert_eq!(params["kind"], "person");
assert_eq!(params["limit"], MAX_LIMIT);
}
#[test]
fn tree_top_entities_rejects_empty_kind() {
// Blank kind is a client bug — the controller would happily run it as
// "no filter" but that's exactly what *omitting* the field already
// means. Rejecting nudges the LLM to drop the field instead.
let err = build_rpc_params("tree.top_entities", json!({ "kind": " " }))
.expect_err("must reject blank-only kind");
assert!(err.message().contains("must not be empty"));
}
// ── tree.list_sources ──────────────────────────────────────────────
#[test]
fn tree_list_sources_accepts_empty_args() {
let params =
build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case");
assert!(params.is_empty());
}
#[test]
fn tree_list_sources_passes_user_email_hint() {
let params = build_rpc_params(
"tree.list_sources",
json!({ "user_email_hint": "me@example.com" }),
)
.expect("params");
assert_eq!(params["user_email_hint"], "me@example.com");
}
#[test]
fn tree_list_sources_rejects_unknown_argument() {
let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 }))
.expect_err("list_sources takes no pagination");
assert!(err.message().contains("unexpected argument `limit`"));
}
// ── memory.store ──────────────────────────────────────────────────
#[test]
fn memory_store_requires_title_and_content() {
let err = build_rpc_params("memory.store", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `title`"));
let err =
build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `content`"));
}
#[test]
fn memory_store_defaults_namespace_to_mcp() {
let params = build_rpc_params(
"memory.store",
json!({ "title": "My note", "content": "Hello world" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["title"], "My note");
assert_eq!(params["content"], "Hello world");
assert_eq!(params["source_type"], "mcp");
assert!(params["key"].as_str().unwrap().starts_with("mcp-store-"));
}
#[test]
fn memory_store_accepts_custom_namespace_and_tags() {
let params = build_rpc_params(
"memory.store",
json!({
"title": "Project Plan",
"content": "Q3 milestones",
"namespace": "work",
"tags": ["project", "planning"]
}),
)
.expect("params");
assert_eq!(params["namespace"], "work");
assert_eq!(params["tags"], json!(["project", "planning"]));
}
#[test]
fn memory_store_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.store",
json!({ "title": "T", "content": "C", "priority": "high" }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `priority`"));
}
// ── memory.note ───────────────────────────────────────────────────
#[test]
fn memory_note_requires_chunk_id_and_note_text() {
let err = build_rpc_params("memory.note", json!({})).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `chunk_id`"));
let err =
build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `note_text`"));
}
#[test]
fn memory_note_builds_annotation_document() {
let params = build_rpc_params(
"memory.note",
json!({ "chunk_id": "chunk-42", "note_text": "Important context" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-note-chunk-42");
assert!(params["title"].as_str().unwrap().contains("chunk-42"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("Important context"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("chunk_id=chunk-42"));
assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42");
assert_eq!(params["source_type"], "mcp");
}
#[test]
fn memory_note_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.note",
json!({ "chunk_id": "abc", "note_text": "N", "extra": true }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `extra`"));
}
// ── tree.tag ──────────────────────────────────────────────────────
#[test]
fn tree_tag_requires_chunk_id_and_tags() {
let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject");
assert!(
err.message()
.contains("missing required argument `chunk_id`"),
"got: {}",
err.message()
);
let err =
build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(
err.message().contains("missing required argument `tags`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_empty_tags_array() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] }))
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_all_blank_tags() {
// After blank-trim the list is empty — same failure mode as `[]`.
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [" ", ""] }),
)
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_non_string_tags() {
// Numeric entries inside `tags` get caught by the string-array helper.
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `tags` must contain only strings"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_builds_tag_record_document() {
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }),
)
.expect("params");
// Document key is deterministic on chunk_id only → re-tagging
// the same chunk upserts.
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-tag-chunk-42");
assert_eq!(params["source_type"], "mcp");
// Title surfaces the target chunk for human-readable recall.
assert!(
params["title"]
.as_str()
.expect("title is a string")
.contains("chunk-42"),
"title was: {}",
params["title"]
);
// Top-level `tags` flows to the document tag index (queryable
// via `doc_list` / search filters) — this is the key differentiator
// from `memory.note` whose payload is opaque free-form text.
assert_eq!(params["tags"], json!(["todo", "q3-planning"]));
// Metadata carries the back-reference plus a mirrored tag list,
// so consumers reading the metadata view don't need to also
// join against the top-level `tags` field.
let metadata = params["metadata"]
.as_object()
.expect("metadata is an object");
assert_eq!(metadata["tags_for_chunk_id"], "chunk-42");
assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"]));
}
#[test]
fn tree_tag_trims_blanks_but_keeps_real_tags() {
// Mixed list — blanks are silently dropped (matches existing
// `optional_string_array` behaviour) but the resulting set is
// still non-empty so the call succeeds.
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }),
)
.expect("params");
assert_eq!(params["tags"], json!(["important", "todo"]));
}
#[test]
fn tree_tag_rejects_empty_chunk_id() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `chunk_id` must not be empty"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_unknown_argument() {
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }),
)
.expect_err("must reject");
assert!(
err.message().contains("unexpected argument `priority`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_tag_array() {
// Per-graycyrus #2316 review: cap the tag-array length so a
// misbehaving client can't flood a chunk's tag-record document
// with hundreds of categorical labels. Builds an over-cap
// array and asserts the dedicated rejection message.
let oversize: Vec<String> = (0..(TREE_TAG_MAX_TAGS + 1))
.map(|i| format!("tag-{i}"))
.collect();
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize }))
.expect_err("must reject");
assert!(
err.message().contains("accepts at most"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_individual_tag() {
// Per-graycyrus #2316 review: a single oversize tag is almost
// certainly free-form text that should be `memory.note` instead
// of going through the categorical tag surface — reject up-front
// so the misuse is visible rather than silently writing a giant
// token into the queryable `tags` index.
let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1);
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [oversize_tag] }),
)
.expect_err("must reject");
assert!(err.message().contains("exceeds"), "got: {}", err.message());
}
#[test]
fn tree_tag_accepts_max_size_tags() {
// Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is
// "at most N", not "fewer than N") with each entry at exactly
// TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the
// inclusive-vs-exclusive bound so a future off-by-one
// refactor breaks the test, not user calls.
let max_tags: Vec<String> = (0..TREE_TAG_MAX_TAGS)
.map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4))
.collect();
// Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars.
assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH));
let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags }))
.expect("at the cap must succeed");
// The built params should preserve all TREE_TAG_MAX_TAGS entries.
assert_eq!(
params["tags"].as_array().expect("tags is array").len(),
TREE_TAG_MAX_TAGS
);
}
#[tokio::test]
async fn call_tool_records_write_argument_rejection() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let config = config_rpc::load_config_with_timeout()
.await
.expect("config");
let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test")
.await
.expect_err("missing content should reject");
assert!(
err.message()
.contains("missing required argument `content`"),
"got: {}",
err.message()
);
let mut rows = Vec::new();
for _ in 0..50 {
rows = crate::openhuman::mcp_audit::list_writes(
&config,
&crate::openhuman::mcp_audit::McpWriteListQuery::default(),
)
.expect("list writes");
if rows.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(rows.len(), 1);
assert!(!rows[0].success);
assert_eq!(rows[0].tool_name, "memory.store");
assert_eq!(rows[0].client_info, "mcp:test");
assert!(rows[0]
.error_message
.as_deref()
.unwrap_or_default()
.contains("missing required argument `content`"));
assert!(rows[0].args_summary.get("content").is_none());
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
// ── slug_from ─────────────────────────────────────────────────────
#[test]
fn slug_from_produces_clean_slug() {
assert_eq!(slug_from("Hello World!"), "hello-world");
assert_eq!(slug_from(" spaces "), "spaces");
assert_eq!(slug_from("CamelCase123"), "camelcase123");
assert_eq!(slug_from("a--b"), "a-b");
}
#[test]
fn slug_from_truncates_long_titles() {
let long = "a".repeat(100);
let slug = slug_from(&long);
assert!(slug.len() <= 64);
}
#[test]
fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() {
// Non-alphanumeric titles should produce "untitled-<hash>" with a
// stable, deterministic hash suffix.
let slug_bang = slug_from("!!!");
let slug_at = slug_from("@@@");
assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}");
assert!(slug_at.starts_with("untitled-"), "got: {slug_at}");
// Different inputs → different slugs
assert_ne!(slug_bang, slug_at);
// Empty title also gets a fallback
assert!(slug_from("").starts_with("untitled-"));
// Stable across calls
assert_eq!(slug_from("!!!"), slug_bang);
}
#[test]
fn slug_from_unicode_only_titles_are_unique_and_stable() {
let chinese = slug_from("会议记录");
let russian = slug_from("Протокол");
let emoji = slug_from("🦀🚀");
// All produce hash-based fallbacks
assert!(chinese.starts_with("untitled-"), "got: {chinese}");
assert!(russian.starts_with("untitled-"), "got: {russian}");
assert!(emoji.starts_with("untitled-"), "got: {emoji}");
// All distinct
assert_ne!(chinese, russian);
assert_ne!(chinese, emoji);
assert_ne!(russian, emoji);
// Stable
assert_eq!(slug_from("会议记录"), chinese);
assert_eq!(slug_from("Протокол"), russian);
}
}
#[path = "tools_tests.rs"]
mod tests;
+906
View File
@@ -0,0 +1,906 @@
use super::*;
#[test]
fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() {
let config = crate::openhuman::config::Config::default();
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk",
"tree.browse",
"tree.top_entities",
"tree.list_sources",
"memory.store",
"memory.note",
"tree.tag",
]
);
}
#[test]
fn list_tools_emits_annotations_for_every_tool() {
// Exercise the searxng-enabled config so the annotation contract covers
// every shipping tool, not just the base set.
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let tools = result["tools"].as_array().expect("tools array");
for tool in tools {
let name = tool["name"].as_str().expect("tool name");
assert!(
tool.get("annotations")
.map(Value::is_object)
.unwrap_or(false),
"tool `{name}` is missing a serialized `annotations` object",
);
}
}
#[test]
fn read_only_tools_are_marked_read_only_and_closed_world() {
// Every tool except the act-capable ones reads local OpenHuman state
// (memory tree / agent registry) or queries an external read-only
// search engine. Per MCP spec defaults these would be
// `readOnlyHint: false` and `openWorldHint: true`, so we MUST set
// `readOnlyHint` explicitly to communicate accurate safety affordances
// to clients. (`searxng_search` is read-only but openWorld, so it
// verifies the read-only axis here and is exempt from the
// openWorld=false check below.)
let act_tool_names = [
"agent.run_subagent",
"memory.store",
"memory.note",
"tree.tag",
];
let open_world_read_only = ["searxng_search"];
for spec in tool_specs() {
if act_tool_names.contains(&spec.name) {
continue;
}
let annotations = &spec.annotations;
assert_eq!(
annotations.get("readOnlyHint").and_then(Value::as_bool),
Some(true),
"expected `{}` to advertise readOnlyHint=true",
spec.name
);
let expected_open_world = open_world_read_only.contains(&spec.name);
assert_eq!(
annotations.get("openWorldHint").and_then(Value::as_bool),
Some(expected_open_world),
"expected `{}` to advertise openWorldHint={}",
spec.name,
expected_open_world
);
// Per spec these are meaningful only when readOnlyHint == false.
// Emitting them on a read-only tool would be misleading.
assert!(
annotations.get("destructiveHint").is_none(),
"read-only tool `{}` should not emit destructiveHint",
spec.name
);
assert!(
annotations.get("idempotentHint").is_none(),
"read-only tool `{}` should not emit idempotentHint",
spec.name
);
}
}
#[test]
fn run_subagent_annotations_signal_act_semantics() {
let spec = tool_specs()
.into_iter()
.find(|spec| spec.name == "agent.run_subagent")
.expect("agent.run_subagent must be registered");
assert_eq!(
spec.annotations
.get("readOnlyHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("destructiveHint")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
spec.annotations
.get("idempotentHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("openWorldHint")
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn list_tools_includes_searxng_when_enabled() {
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert!(names.contains(&"searxng_search"));
}
#[test]
fn mapped_rpc_methods_are_registered() {
for spec in tool_specs() {
if let Some(rpc_method) = spec.rpc_method {
assert!(
all::schema_for_rpc_method(rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
rpc_method
);
}
}
}
#[test]
fn build_rpc_params_parses_run_subagent_arguments() {
let params = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause."
}),
)
.expect("params should parse");
assert_eq!(
params.get("agent_id").and_then(Value::as_str),
Some("researcher")
);
assert_eq!(
params.get("prompt").and_then(Value::as_str),
Some("Find the root cause.")
);
}
#[test]
fn build_rpc_params_rejects_extra_run_subagent_fields() {
let err = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause.",
"toolkit": "gmail"
}),
)
.expect_err("unexpected field should be rejected");
assert!(
matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument"))
);
}
#[test]
fn memory_search_params_trim_query_and_use_default_k() {
let params = build_rpc_params(
"memory.search",
json!({
"query": " phoenix migration ",
}),
)
.expect("params");
assert_eq!(params["query"], "phoenix migration");
assert_eq!(params["k"], DEFAULT_LIMIT);
}
#[test]
fn searxng_search_params_accept_optional_fields() {
let params = build_rpc_params(
"searxng_search",
json!({
"query": " rust async ",
"categories": ["web", "news"],
"language": " en ",
"max_results": 12
}),
)
.expect("params");
assert_eq!(params["query"], "rust async");
assert_eq!(params["categories"], json!(["web", "news"]));
assert_eq!(params["language"], "en");
assert_eq!(params["max_results"], 12);
}
#[test]
fn searxng_search_rejects_unknown_category() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"categories": ["videos"]
}),
)
.expect_err("must reject");
assert!(err.message().contains("unsupported SearXNG category"));
}
#[test]
fn searxng_search_rejects_max_results_above_max() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"max_results": SEARXNG_MAX_RESULTS + 1
}),
)
.expect_err("must reject");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn memory_search_rejects_k_above_max() {
// Reject (don't silent-clamp) so the LLM can self-correct on the next
// call. Silent clamping makes the model believe it got the page size
// it asked for and prevents the corrective feedback loop.
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"k": MAX_LIMIT + 1
}),
)
.expect_err("must reject k > MAX_LIMIT");
let message = err.message();
assert!(
message.contains("must not exceed"),
"error should mention the cap, got: {message}"
);
assert!(
message.contains(&MAX_LIMIT.to_string()),
"error should mention the limit value, got: {message}"
);
}
#[test]
fn memory_search_accepts_k_at_max() {
let params = build_rpc_params(
"memory.search",
json!({ "query": "phoenix", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT must be accepted (boundary inclusive)");
assert_eq!(params["k"], MAX_LIMIT);
}
#[test]
fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() {
let err = ToolCallError::InvalidParams("missing query".to_string());
assert_eq!(err.code(), -32602);
assert_eq!(err.jsonrpc_message(), "Invalid params");
assert_eq!(err.message(), "missing query");
}
#[test]
fn tool_call_error_internal_maps_to_jsonrpc_internal_error() {
// Server-side failures (config load, missing resources) must surface
// as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP
// client doesn't mislead the user / LLM into retrying with different
// arguments.
let err = ToolCallError::Internal("disk read failed".to_string());
assert_eq!(err.code(), -32603);
assert_eq!(err.jsonrpc_message(), "Internal error");
assert_eq!(err.message(), "disk read failed");
}
#[test]
fn memory_recall_requires_query() {
let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `query`"));
}
#[test]
fn memory_search_rejects_undocumented_limit_alias() {
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"limit": 5
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_read_chunk_maps_chunk_id_to_controller_id() {
let params = build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params");
assert_eq!(params["id"], "abc");
assert!(!params.contains_key("chunk_id"));
}
#[test]
fn tree_read_chunk_rejects_unknown_arguments() {
let err = build_rpc_params(
"tree.read_chunk",
json!({
"chunk_id": "abc",
"unused": true
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `unused`"));
}
#[test]
fn non_object_arguments_are_invalid() {
let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject");
assert!(err.message().contains("arguments must be an object"));
}
// ── tree.browse ────────────────────────────────────────────────────
#[test]
fn tree_browse_no_args_sends_default_limit_only() {
// Empty filter is a valid request — the controller treats unset filters
// as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT
// so the LLM doesn't accidentally pull the controller's 50-row default
// when it asked for nothing.
let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid");
assert_eq!(params.len(), 1);
assert_eq!(params["limit"], DEFAULT_LIMIT);
}
#[test]
fn tree_browse_passes_through_filters_and_renames_k_to_limit() {
let params = build_rpc_params(
"tree.browse",
json!({
"source_kinds": ["email", "chat"],
"source_ids": ["acme-thread-1"],
"entity_ids": ["person:Alice"],
"since_ms": 1_700_000_000_000_i64,
"until_ms": 1_710_000_000_000_i64,
"query": "Q3 plan",
"k": 20,
"offset": 10
}),
)
.expect("params");
assert_eq!(params["limit"], 20);
assert!(!params.contains_key("k"));
assert_eq!(params["source_kinds"], json!(["email", "chat"]));
assert_eq!(params["source_ids"], json!(["acme-thread-1"]));
assert_eq!(params["entity_ids"], json!(["person:Alice"]));
assert_eq!(params["since_ms"], 1_700_000_000_000_i64);
assert_eq!(params["until_ms"], 1_710_000_000_000_i64);
assert_eq!(params["query"], "Q3 plan");
assert_eq!(params["offset"], 10);
}
#[test]
fn tree_browse_rejects_k_above_max() {
// Same reject-don't-clamp policy as memory.search / memory.recall so the
// LLM gets corrective feedback instead of silently receiving fewer rows
// than it asked for.
let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 }))
.expect_err("must reject k > MAX_LIMIT");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn tree_browse_rejects_unknown_argument() {
let err = build_rpc_params("tree.browse", json!({ "limit": 10 }))
.expect_err("must reject the controller's `limit` alias");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_browse_rejects_non_array_source_kinds() {
let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" }))
.expect_err("must reject scalar where array is required");
assert!(err.message().contains("must be an array of strings"));
}
#[test]
fn tree_browse_rejects_non_integer_since_ms() {
let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" }))
.expect_err("must reject ISO-style date for ms field");
assert!(err.message().contains("must be an integer"));
}
#[test]
fn tree_browse_drops_blank_array_entries_silently() {
// Empty / whitespace strings inside an array are tolerated — clients
// sometimes send `["", "email"]` after a partial UI selection and the
// intent ("filter to email") is unambiguous. A fully-blank array is OK
// too and produces an empty filter (same as omitting the field).
let params = build_rpc_params(
"tree.browse",
json!({ "source_kinds": ["", "email", " "] }),
)
.expect("blank entries don't fail the whole call");
assert_eq!(params["source_kinds"], json!(["email"]));
}
// ── tree.top_entities ──────────────────────────────────────────────
#[test]
fn tree_top_entities_defaults_limit_and_omits_kind() {
let params = build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid");
assert_eq!(params["limit"], DEFAULT_LIMIT);
assert!(!params.contains_key("kind"));
}
#[test]
fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() {
let params = build_rpc_params(
"tree.top_entities",
json!({ "kind": "person", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT is the boundary, inclusive");
assert_eq!(params["kind"], "person");
assert_eq!(params["limit"], MAX_LIMIT);
}
#[test]
fn tree_top_entities_rejects_empty_kind() {
// Blank kind is a client bug — the controller would happily run it as
// "no filter" but that's exactly what *omitting* the field already
// means. Rejecting nudges the LLM to drop the field instead.
let err = build_rpc_params("tree.top_entities", json!({ "kind": " " }))
.expect_err("must reject blank-only kind");
assert!(err.message().contains("must not be empty"));
}
// ── tree.list_sources ──────────────────────────────────────────────
#[test]
fn tree_list_sources_accepts_empty_args() {
let params =
build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case");
assert!(params.is_empty());
}
#[test]
fn tree_list_sources_passes_user_email_hint() {
let params = build_rpc_params(
"tree.list_sources",
json!({ "user_email_hint": "me@example.com" }),
)
.expect("params");
assert_eq!(params["user_email_hint"], "me@example.com");
}
#[test]
fn tree_list_sources_rejects_unknown_argument() {
let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 }))
.expect_err("list_sources takes no pagination");
assert!(err.message().contains("unexpected argument `limit`"));
}
// ── memory.store ──────────────────────────────────────────────────
#[test]
fn memory_store_requires_title_and_content() {
let err = build_rpc_params("memory.store", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `title`"));
let err = build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `content`"));
}
#[test]
fn memory_store_defaults_namespace_to_mcp() {
let params = build_rpc_params(
"memory.store",
json!({ "title": "My note", "content": "Hello world" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["title"], "My note");
assert_eq!(params["content"], "Hello world");
assert_eq!(params["source_type"], "mcp");
assert!(params["key"].as_str().unwrap().starts_with("mcp-store-"));
}
#[test]
fn memory_store_accepts_custom_namespace_and_tags() {
let params = build_rpc_params(
"memory.store",
json!({
"title": "Project Plan",
"content": "Q3 milestones",
"namespace": "work",
"tags": ["project", "planning"]
}),
)
.expect("params");
assert_eq!(params["namespace"], "work");
assert_eq!(params["tags"], json!(["project", "planning"]));
}
#[test]
fn memory_store_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.store",
json!({ "title": "T", "content": "C", "priority": "high" }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `priority`"));
}
// ── memory.note ───────────────────────────────────────────────────
#[test]
fn memory_note_requires_chunk_id_and_note_text() {
let err = build_rpc_params("memory.note", json!({})).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `chunk_id`"));
let err =
build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `note_text`"));
}
#[test]
fn memory_note_builds_annotation_document() {
let params = build_rpc_params(
"memory.note",
json!({ "chunk_id": "chunk-42", "note_text": "Important context" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-note-chunk-42");
assert!(params["title"].as_str().unwrap().contains("chunk-42"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("Important context"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("chunk_id=chunk-42"));
assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42");
assert_eq!(params["source_type"], "mcp");
}
#[test]
fn memory_note_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.note",
json!({ "chunk_id": "abc", "note_text": "N", "extra": true }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `extra`"));
}
// ── tree.tag ──────────────────────────────────────────────────────
#[test]
fn tree_tag_requires_chunk_id_and_tags() {
let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject");
assert!(
err.message()
.contains("missing required argument `chunk_id`"),
"got: {}",
err.message()
);
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(
err.message().contains("missing required argument `tags`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_empty_tags_array() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] }))
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_all_blank_tags() {
// After blank-trim the list is empty — same failure mode as `[]`.
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [" ", ""] }),
)
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_non_string_tags() {
// Numeric entries inside `tags` get caught by the string-array helper.
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `tags` must contain only strings"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_builds_tag_record_document() {
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }),
)
.expect("params");
// Document key is deterministic on chunk_id only → re-tagging
// the same chunk upserts.
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-tag-chunk-42");
assert_eq!(params["source_type"], "mcp");
// Title surfaces the target chunk for human-readable recall.
assert!(
params["title"]
.as_str()
.expect("title is a string")
.contains("chunk-42"),
"title was: {}",
params["title"]
);
// Top-level `tags` flows to the document tag index (queryable
// via `doc_list` / search filters) — this is the key differentiator
// from `memory.note` whose payload is opaque free-form text.
assert_eq!(params["tags"], json!(["todo", "q3-planning"]));
// Metadata carries the back-reference plus a mirrored tag list,
// so consumers reading the metadata view don't need to also
// join against the top-level `tags` field.
let metadata = params["metadata"]
.as_object()
.expect("metadata is an object");
assert_eq!(metadata["tags_for_chunk_id"], "chunk-42");
assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"]));
}
#[test]
fn tree_tag_trims_blanks_but_keeps_real_tags() {
// Mixed list — blanks are silently dropped (matches existing
// `optional_string_array` behaviour) but the resulting set is
// still non-empty so the call succeeds.
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }),
)
.expect("params");
assert_eq!(params["tags"], json!(["important", "todo"]));
}
#[test]
fn tree_tag_rejects_empty_chunk_id() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `chunk_id` must not be empty"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_unknown_argument() {
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }),
)
.expect_err("must reject");
assert!(
err.message().contains("unexpected argument `priority`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_tag_array() {
// Per-graycyrus #2316 review: cap the tag-array length so a
// misbehaving client can't flood a chunk's tag-record document
// with hundreds of categorical labels. Builds an over-cap
// array and asserts the dedicated rejection message.
let oversize: Vec<String> = (0..(TREE_TAG_MAX_TAGS + 1))
.map(|i| format!("tag-{i}"))
.collect();
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize }))
.expect_err("must reject");
assert!(
err.message().contains("accepts at most"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_individual_tag() {
// Per-graycyrus #2316 review: a single oversize tag is almost
// certainly free-form text that should be `memory.note` instead
// of going through the categorical tag surface — reject up-front
// so the misuse is visible rather than silently writing a giant
// token into the queryable `tags` index.
let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1);
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [oversize_tag] }),
)
.expect_err("must reject");
assert!(err.message().contains("exceeds"), "got: {}", err.message());
}
#[test]
fn tree_tag_accepts_max_size_tags() {
// Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is
// "at most N", not "fewer than N") with each entry at exactly
// TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the
// inclusive-vs-exclusive bound so a future off-by-one
// refactor breaks the test, not user calls.
let max_tags: Vec<String> = (0..TREE_TAG_MAX_TAGS)
.map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4))
.collect();
// Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars.
assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH));
let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags }))
.expect("at the cap must succeed");
// The built params should preserve all TREE_TAG_MAX_TAGS entries.
assert_eq!(
params["tags"].as_array().expect("tags is array").len(),
TREE_TAG_MAX_TAGS
);
}
#[tokio::test]
async fn call_tool_records_write_argument_rejection() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let config = config_rpc::load_config_with_timeout()
.await
.expect("config");
let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test")
.await
.expect_err("missing content should reject");
assert!(
err.message()
.contains("missing required argument `content`"),
"got: {}",
err.message()
);
let mut rows = Vec::new();
for _ in 0..50 {
rows = crate::openhuman::mcp_audit::list_writes(
&config,
&crate::openhuman::mcp_audit::McpWriteListQuery::default(),
)
.expect("list writes");
if rows.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(rows.len(), 1);
assert!(!rows[0].success);
assert_eq!(rows[0].tool_name, "memory.store");
assert_eq!(rows[0].client_info, "mcp:test");
assert!(rows[0]
.error_message
.as_deref()
.unwrap_or_default()
.contains("missing required argument `content`"));
assert!(rows[0].args_summary.get("content").is_none());
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
// ── slug_from ─────────────────────────────────────────────────────
#[test]
fn slug_from_produces_clean_slug() {
assert_eq!(slug_from("Hello World!"), "hello-world");
assert_eq!(slug_from(" spaces "), "spaces");
assert_eq!(slug_from("CamelCase123"), "camelcase123");
assert_eq!(slug_from("a--b"), "a-b");
}
#[test]
fn slug_from_truncates_long_titles() {
let long = "a".repeat(100);
let slug = slug_from(&long);
assert!(slug.len() <= 64);
}
#[test]
fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() {
// Non-alphanumeric titles should produce "untitled-<hash>" with a
// stable, deterministic hash suffix.
let slug_bang = slug_from("!!!");
let slug_at = slug_from("@@@");
assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}");
assert!(slug_at.starts_with("untitled-"), "got: {slug_at}");
// Different inputs → different slugs
assert_ne!(slug_bang, slug_at);
// Empty title also gets a fallback
assert!(slug_from("").starts_with("untitled-"));
// Stable across calls
assert_eq!(slug_from("!!!"), slug_bang);
}
#[test]
fn slug_from_unicode_only_titles_are_unique_and_stable() {
let chinese = slug_from("会议记录");
let russian = slug_from("Протокол");
let emoji = slug_from("🦀🚀");
// All produce hash-based fallbacks
assert!(chinese.starts_with("untitled-"), "got: {chinese}");
assert!(russian.starts_with("untitled-"), "got: {russian}");
assert!(emoji.starts_with("untitled-"), "got: {emoji}");
// All distinct
assert_ne!(chinese, russian);
assert_ne!(chinese, emoji);
assert_ne!(russian, emoji);
// Stable
assert_eq!(slug_from("会议记录"), chinese);
assert_eq!(slug_from("Протокол"), russian);
}
+2 -2
View File
@@ -36,9 +36,9 @@ pub mod tree_source;
pub mod tree_topic;
#[cfg(test)]
mod sync_pipeline_e2e_test;
mod sync_pipeline_e2e_tests;
#[cfg(test)]
mod tree_e2e_test;
mod tree_e2e_tests;
pub use ingestion::{
ExtractedEntity, ExtractedRelation, ExtractionMode, IngestionJob, IngestionQueue,
IngestionState, IngestionStatusSnapshot, MemoryIngestionConfig, MemoryIngestionRequest,
+2 -781
View File
@@ -1730,784 +1730,5 @@ fn parse_source_kind_str(s: &str) -> Option<SourceKind> {
// ── Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE;
use crate::openhuman::embeddings::NoopEmbedding;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_store::unified::UnifiedMemory;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page;
use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage;
use chrono::{TimeZone, Utc};
use rusqlite::params;
use std::sync::Arc;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
// Point config_path inside the tempdir so any persistence during
// tests stays inside disposable workspace state.
cfg.config_path = tmp.path().join("config.toml");
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
// Default llm is Cloud — but the cloud provider needs a bearer
// token to actually fire. Tests that exercise the LLM path
// override either the backend or the extractor. The read RPCs
// below don't touch the LLM, so this default is fine.
(tmp, cfg)
}
async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) {
let batch = ChatBatch {
platform: "slack".into(),
channel_label: source.into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: body.into(),
source_ref: Some("slack://x".into()),
}],
};
ingest_chat(cfg, source, "alice", vec![], batch)
.await
.unwrap();
}
async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String {
let msg = SlackMessage {
channel_id: "C123".into(),
channel_name: "engineering".into(),
is_private: false,
author: "alice".into(),
author_id: "U123".into(),
text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(),
timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
ts_raw: "1700000000.000100".into(),
thread_ts: None,
permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()),
};
ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg])
.await
.expect("seed slack ingest");
drain_until_idle(cfg).await.expect("drain slack ingest");
list_chunks_rpc(cfg, ChunkFilter::default())
.await
.expect("list chunks")
.value
.chunks
.into_iter()
.find(|chunk| chunk.source_id == "slack:conn-slack-1")
.expect("seeded slack chunk")
.id
}
fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) {
with_connection(cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks
SET timestamp_ms = ?1,
time_range_start_ms = ?1,
time_range_end_ms = ?1
WHERE id = ?2",
params![timestamp_ms, chunk_id],
)?;
Ok(())
})
.unwrap();
}
fn insert_raw_chunk(
cfg: &Config,
id: &str,
source_kind: &str,
source_id: &str,
timestamp_ms: i64,
tags_json: &str,
content: &str,
token_count: i64,
) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner, timestamp_ms,
time_range_start_ms, time_range_end_ms, tags_json, content,
token_count, seq_in_source, created_at_ms, lifecycle_status, content_path
) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)",
params![id, source_kind, source_id, timestamp_ms, tags_json, content, token_count],
)?;
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn list_chunks_returns_seeded_chunk() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await;
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(!resp.chunks.is_empty());
assert_eq!(resp.total, resp.chunks.len() as u64);
}
#[tokio::test]
async fn list_chunks_filters_by_source_id() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let only_a = list_chunks_rpc(
&cfg,
ChunkFilter {
source_ids: Some(vec!["slack:#a".into()]),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a"));
assert!(only_a.total >= 1);
}
#[tokio::test]
async fn list_chunks_query_substring_works() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
query: Some("phoenix".into()),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(resp.chunks.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "first chat").await;
seed_chat_chunk(&cfg, "slack:#b", "second chat").await;
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec!["chat".into()]),
limit: Some(1),
offset: Some(1),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.total, 2);
assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat"));
}
#[tokio::test]
async fn list_chunks_filters_by_entity_id_and_time_window() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await;
seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await;
let seeded = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let alice = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("alice@example.com")
})
.expect("alice chunk present");
let bob = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("bob@example.com")
})
.expect("bob chunk present");
update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100);
update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900);
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
entity_ids: Some(vec!["email:alice@example.com".into()]),
since_ms: Some(1_700_000_000_000),
until_ms: Some(1_700_000_000_500),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.total, 1);
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.chunks[0].id, alice.id);
}
#[tokio::test]
async fn list_chunks_ignores_empty_filter_lists_and_blank_query() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec![]),
source_ids: Some(vec![]),
entity_ids: Some(vec![]),
query: Some(" ".into()),
limit: Some(10),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(resp.total, 2);
assert_eq!(resp.chunks.len(), 2);
}
#[tokio::test]
async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"raw-empty",
"document",
"notion:page-1",
1_700_000_000_123,
"not-json",
"",
-7,
);
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
let row = resp
.chunks
.into_iter()
.find(|chunk| chunk.id == "raw-empty")
.expect("raw chunk listed");
assert_eq!(row.token_count, 0);
assert_eq!(row.tags, Vec::<String>::new());
assert_eq!(row.content_preview, None);
assert!(!row.has_embedding);
}
#[tokio::test]
async fn list_sources_aggregates() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "x").await;
seed_chat_chunk(&cfg, "slack:#a", "y").await;
seed_chat_chunk(&cfg, "slack:#b", "z").await;
let sources = list_sources_rpc(&cfg, None).await.unwrap().value;
let a = sources
.iter()
.find(|s| s.source_id == "slack:#a")
.expect("expected slack:#a");
let b = sources
.iter()
.find(|s| s.source_id == "slack:#b")
.expect("expected slack:#b");
assert_eq!(a.chunk_count, 2);
assert_eq!(b.chunk_count, 1);
}
#[tokio::test]
async fn list_sources_formats_email_threads_with_trimmed_user_hint() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"email-thread",
"email",
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
1_700_000_000_123,
"[]",
"thread body",
12,
);
let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into()))
.await
.unwrap()
.value;
let source = sources
.iter()
.find(|row| {
row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com"
})
.expect("email thread source present");
assert_eq!(source.display_name, "bob@example.com, carol@example.com");
}
#[tokio::test]
async fn entity_index_for_returns_extracted_entities() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
// Find the chunk we just seeded.
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(
refs.iter().any(|r| r.entity_id.contains("alice")),
"expected alice entity in index, got: {refs:?}"
);
}
#[tokio::test]
async fn chunks_for_entity_returns_leaf_chunk_ids_only() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks[0]
.id
.clone();
let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into())
.await
.unwrap()
.value;
assert_eq!(rows, vec![chunk_id]);
}
#[tokio::test]
async fn top_entities_returns_most_frequent() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await;
seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await;
seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await;
let top = top_entities_rpc(&cfg, Some("email".into()), 10)
.await
.unwrap()
.value;
assert!(top
.iter()
.any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2));
}
#[tokio::test]
async fn delete_chunk_removes_chunk_and_dependent_rows() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = chunks[0].id.clone();
let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(resp.deleted);
// Re-list — the chunk should be gone.
let after = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(after.chunks.iter().all(|c| c.id != id));
}
#[tokio::test]
async fn delete_missing_chunk_is_idempotent() {
let (_tmp, cfg) = test_config();
let resp = delete_chunk_rpc(&cfg, "does-not-exist".into())
.await
.unwrap()
.value;
assert!(!resp.deleted);
assert_eq!(resp.score_rows_removed, 0);
}
#[tokio::test]
async fn chunk_score_returns_breakdown_after_ingest() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"alice@example.com owns the phoenix migration",
)
.await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(breakdown.is_some(), "expected score row after ingest");
let b = breakdown.unwrap();
assert!(b.signals.iter().any(|s| s.name == "metadata_weight"));
assert!(b.threshold > 0.0);
}
#[tokio::test]
async fn search_returns_matching_chunks() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value;
assert!(hits.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn read_chunk_row_returns_preview_and_metadata() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"phoenix migration scheduled friday with context and source refs",
)
.await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.id, chunk.id);
assert_eq!(row.source_kind, "chat");
assert_eq!(row.source_id, "slack:#eng");
assert_eq!(row.source_ref.as_deref(), Some("slack://x"));
assert_eq!(row.owner, "alice");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(row.content_path.is_some());
assert!(row
.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix migration scheduled friday"));
}
#[tokio::test]
async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() {
let (_tmp, cfg) = test_config();
let body = "sqlite preview survives missing file";
seed_chat_chunk(&cfg, "slack:#eng", body).await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let rel_path = chunk.content_path.clone().expect("content path present");
let abs_path = cfg.memory_tree_content_root().join(rel_path);
std::fs::remove_file(&abs_path).expect("remove chunk file");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.content_path, chunk.content_path);
assert!(row.content_preview.as_deref().unwrap_or("").contains(body));
}
#[tokio::test]
async fn flush_now_enqueues_once_and_reports_stale_buffers() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"Phoenix migration ships Friday after the release checklist closes.",
)
.await;
drain_until_idle(&cfg).await.expect("drain jobs");
let first = flush_now_rpc(&cfg).await.expect("flush_now first");
assert!(first.value.enqueued, "first flush should enqueue work");
assert!(
first.value.stale_buffers >= 1,
"expected at least one stale buffer after ingest"
);
let second = flush_now_rpc(&cfg).await.expect("flush_now second");
assert!(
!second.value.enqueued,
"same 3-hour window should dedupe duplicate flush triggers"
);
assert!(
second.value.stale_buffers >= 1,
"deduped flush should still report current stale buffer count"
);
}
#[tokio::test]
async fn reset_tree_preserves_raw_archive_and_source_registry() {
let (_tmp, cfg) = test_config();
let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await;
let content_root = cfg.memory_tree_content_root();
let raw_file = content_root
.join("raw")
.join("slack-conn-slack-1")
.join("chats")
.join("1700000000000_1700000000.000100.md");
let source_file = content_root
.join("raw")
.join("slack-conn-slack-1")
.join("_source.md");
assert!(raw_file.exists(), "raw archive should exist before reset");
assert!(
source_file.exists(),
"source registry should exist before reset"
);
let stale_summary = content_root
.join("wiki")
.join("summaries")
.join("source-slack-conn-slack-1")
.join("L1")
.join("summary-stale.md");
std::fs::create_dir_all(
stale_summary
.parent()
.expect("stale summary parent should exist"),
)
.expect("create stale summary dir");
std::fs::write(&stale_summary, "stale summary body").expect("write stale summary");
assert!(stale_summary.exists(), "stale summary fixture should exist");
let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree");
assert_eq!(outcome.value.chunks_requeued, 1);
assert_eq!(outcome.value.jobs_enqueued, 1);
assert!(
outcome.value.tree_rows_deleted >= 1,
"buffer/tree rows should be removed during reset"
);
let row = read_chunk_row(&cfg, &chunk_id)
.expect("read chunk row")
.expect("chunk row present after reset");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(raw_file.exists(), "raw archive must survive reset_tree");
assert!(
source_file.exists(),
"source registry must survive reset_tree"
);
assert!(
!content_root.join("wiki").join("summaries").exists(),
"derived wiki summaries should be removed"
);
}
#[test]
fn read_chunk_row_returns_none_for_missing_chunk() {
let (_tmp, cfg) = test_config();
assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none());
}
#[test]
fn display_name_unslugs_email_thread_with_user_hint() {
let name = display_name_for_source(
"gmail:alice@example.com|bob@example.com",
Some("alice@example.com"),
);
assert_eq!(name, "bob@example.com");
}
#[test]
fn display_name_falls_back_to_arrow_when_user_unknown() {
let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None);
assert!(name.contains("alice@example.com"));
assert!(name.contains("bob@example.com"));
assert!(name.contains(""));
}
#[test]
fn display_name_strips_platform_prefix() {
assert_eq!(
display_name_for_source("slack:#engineering", None),
"#engineering"
);
}
#[test]
fn display_name_handles_multiple_participants_and_trimmed_hint() {
let name = display_name_for_source(
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
Some(" alice@example.com "),
);
assert_eq!(name, "bob@example.com, carol@example.com");
}
#[test]
fn display_name_handles_no_prefix() {
assert_eq!(display_name_for_source("loose-id", None), "loose-id");
}
#[test]
fn sanitize_basename_replaces_windows_illegal_characters() {
assert_eq!(
sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#),
"chat-slack-#eng-name------"
);
assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md");
}
#[test]
fn parse_source_kind_str_accepts_known_values_only() {
assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat));
assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email));
assert_eq!(
parse_source_kind_str("document"),
Some(SourceKind::Document)
);
assert_eq!(parse_source_kind_str("unknown"), None);
}
#[test]
fn clear_composio_sync_state_removes_only_target_namespace() {
let tmp = TempDir::new().unwrap();
let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
let db_path = tmp.path().join("memory").join("memory.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES (?1, 'cursor', '{}', 1.0)",
params![KV_NAMESPACE],
)
.unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES ('other-namespace', 'cursor', '{}', 2.0)",
[],
)
.unwrap();
drop(conn);
let removed = clear_composio_sync_state(&db_path).unwrap();
assert_eq!(removed, 1);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let composio_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1",
params![KV_NAMESPACE],
|row| row.get(0),
)
.unwrap();
let other_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(composio_count, 0);
assert_eq!(other_count, 1);
}
#[tokio::test]
async fn obsidian_status_registered_when_override_config_lists_content_root() {
let (_tmp, cfg) = test_config();
let content_root = cfg.memory_tree_content_root();
// A separate dir standing in for a non-standard Obsidian config
// location, with an obsidian.json that registers the content root.
let cfg_dir = TempDir::new().unwrap();
let body = format!(
"{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}",
serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap()
);
std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(outcome.value.registered);
assert!(outcome.value.config_found);
assert_eq!(
outcome.value.content_root_abs,
content_root.to_string_lossy().to_string()
);
// The log reports the booleans but redacts the absolute path (it
// embeds the user's home / username).
assert!(
outcome.logs[0].contains("registered=true"),
"log: {}",
outcome.logs[0]
);
assert!(
!outcome.logs[0].contains(content_root.to_str().unwrap()),
"log leaked content root: {}",
outcome.logs[0]
);
}
#[tokio::test]
async fn obsidian_status_not_registered_for_empty_override_dir() {
let (_tmp, cfg) = test_config();
// Empty override dir → no obsidian.json there → content root is not a
// registered vault. (A temp content root can't be under any real host
// vault either, so this stays false regardless of the dev machine.)
let cfg_dir = TempDir::new().unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
#[tokio::test]
async fn obsidian_status_blank_override_is_treated_as_none() {
// A whitespace-only override must be normalized to None rather than
// resolving to "." and probing a stray local ./obsidian.json. The temp
// content root isn't under any real host vault, so this stays false.
let (_tmp, cfg) = test_config();
let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
}
#[path = "read_rpc_tests.rs"]
mod tests;
+785
View File
@@ -0,0 +1,785 @@
use super::*;
use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE;
use crate::openhuman::embeddings::NoopEmbedding;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_store::unified::UnifiedMemory;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page;
use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage;
use chrono::{TimeZone, Utc};
use rusqlite::params;
use std::sync::Arc;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
// Point config_path inside the tempdir so any persistence during
// tests stays inside disposable workspace state.
cfg.config_path = tmp.path().join("config.toml");
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
// Default llm is Cloud — but the cloud provider needs a bearer
// token to actually fire. Tests that exercise the LLM path
// override either the backend or the extractor. The read RPCs
// below don't touch the LLM, so this default is fine.
(tmp, cfg)
}
async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) {
let batch = ChatBatch {
platform: "slack".into(),
channel_label: source.into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: body.into(),
source_ref: Some("slack://x".into()),
}],
};
ingest_chat(cfg, source, "alice", vec![], batch)
.await
.unwrap();
}
async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String {
let msg = SlackMessage {
channel_id: "C123".into(),
channel_name: "engineering".into(),
is_private: false,
author: "alice".into(),
author_id: "U123".into(),
text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(),
timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
ts_raw: "1700000000.000100".into(),
thread_ts: None,
permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()),
};
ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg])
.await
.expect("seed slack ingest");
drain_until_idle(cfg).await.expect("drain slack ingest");
list_chunks_rpc(cfg, ChunkFilter::default())
.await
.expect("list chunks")
.value
.chunks
.into_iter()
.find(|chunk| chunk.source_id == "slack:conn-slack-1")
.expect("seeded slack chunk")
.id
}
fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) {
with_connection(cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks
SET timestamp_ms = ?1,
time_range_start_ms = ?1,
time_range_end_ms = ?1
WHERE id = ?2",
params![timestamp_ms, chunk_id],
)?;
Ok(())
})
.unwrap();
}
fn insert_raw_chunk(
cfg: &Config,
id: &str,
source_kind: &str,
source_id: &str,
timestamp_ms: i64,
tags_json: &str,
content: &str,
token_count: i64,
) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner, timestamp_ms,
time_range_start_ms, time_range_end_ms, tags_json, content,
token_count, seq_in_source, created_at_ms, lifecycle_status, content_path
) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)",
params![
id,
source_kind,
source_id,
timestamp_ms,
tags_json,
content,
token_count
],
)?;
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn list_chunks_returns_seeded_chunk() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await;
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(!resp.chunks.is_empty());
assert_eq!(resp.total, resp.chunks.len() as u64);
}
#[tokio::test]
async fn list_chunks_filters_by_source_id() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let only_a = list_chunks_rpc(
&cfg,
ChunkFilter {
source_ids: Some(vec!["slack:#a".into()]),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a"));
assert!(only_a.total >= 1);
}
#[tokio::test]
async fn list_chunks_query_substring_works() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
query: Some("phoenix".into()),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(resp.chunks.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "first chat").await;
seed_chat_chunk(&cfg, "slack:#b", "second chat").await;
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec!["chat".into()]),
limit: Some(1),
offset: Some(1),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.total, 2);
assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat"));
}
#[tokio::test]
async fn list_chunks_filters_by_entity_id_and_time_window() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await;
seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await;
let seeded = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let alice = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("alice@example.com")
})
.expect("alice chunk present");
let bob = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("bob@example.com")
})
.expect("bob chunk present");
update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100);
update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900);
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
entity_ids: Some(vec!["email:alice@example.com".into()]),
since_ms: Some(1_700_000_000_000),
until_ms: Some(1_700_000_000_500),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.total, 1);
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.chunks[0].id, alice.id);
}
#[tokio::test]
async fn list_chunks_ignores_empty_filter_lists_and_blank_query() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec![]),
source_ids: Some(vec![]),
entity_ids: Some(vec![]),
query: Some(" ".into()),
limit: Some(10),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(resp.total, 2);
assert_eq!(resp.chunks.len(), 2);
}
#[tokio::test]
async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"raw-empty",
"document",
"notion:page-1",
1_700_000_000_123,
"not-json",
"",
-7,
);
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
let row = resp
.chunks
.into_iter()
.find(|chunk| chunk.id == "raw-empty")
.expect("raw chunk listed");
assert_eq!(row.token_count, 0);
assert_eq!(row.tags, Vec::<String>::new());
assert_eq!(row.content_preview, None);
assert!(!row.has_embedding);
}
#[tokio::test]
async fn list_sources_aggregates() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "x").await;
seed_chat_chunk(&cfg, "slack:#a", "y").await;
seed_chat_chunk(&cfg, "slack:#b", "z").await;
let sources = list_sources_rpc(&cfg, None).await.unwrap().value;
let a = sources
.iter()
.find(|s| s.source_id == "slack:#a")
.expect("expected slack:#a");
let b = sources
.iter()
.find(|s| s.source_id == "slack:#b")
.expect("expected slack:#b");
assert_eq!(a.chunk_count, 2);
assert_eq!(b.chunk_count, 1);
}
#[tokio::test]
async fn list_sources_formats_email_threads_with_trimmed_user_hint() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"email-thread",
"email",
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
1_700_000_000_123,
"[]",
"thread body",
12,
);
let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into()))
.await
.unwrap()
.value;
let source = sources
.iter()
.find(|row| row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com")
.expect("email thread source present");
assert_eq!(source.display_name, "bob@example.com, carol@example.com");
}
#[tokio::test]
async fn entity_index_for_returns_extracted_entities() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
// Find the chunk we just seeded.
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(
refs.iter().any(|r| r.entity_id.contains("alice")),
"expected alice entity in index, got: {refs:?}"
);
}
#[tokio::test]
async fn chunks_for_entity_returns_leaf_chunk_ids_only() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks[0]
.id
.clone();
let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into())
.await
.unwrap()
.value;
assert_eq!(rows, vec![chunk_id]);
}
#[tokio::test]
async fn top_entities_returns_most_frequent() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await;
seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await;
seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await;
let top = top_entities_rpc(&cfg, Some("email".into()), 10)
.await
.unwrap()
.value;
assert!(top
.iter()
.any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2));
}
#[tokio::test]
async fn delete_chunk_removes_chunk_and_dependent_rows() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = chunks[0].id.clone();
let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(resp.deleted);
// Re-list — the chunk should be gone.
let after = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(after.chunks.iter().all(|c| c.id != id));
}
#[tokio::test]
async fn delete_missing_chunk_is_idempotent() {
let (_tmp, cfg) = test_config();
let resp = delete_chunk_rpc(&cfg, "does-not-exist".into())
.await
.unwrap()
.value;
assert!(!resp.deleted);
assert_eq!(resp.score_rows_removed, 0);
}
#[tokio::test]
async fn chunk_score_returns_breakdown_after_ingest() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"alice@example.com owns the phoenix migration",
)
.await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(breakdown.is_some(), "expected score row after ingest");
let b = breakdown.unwrap();
assert!(b.signals.iter().any(|s| s.name == "metadata_weight"));
assert!(b.threshold > 0.0);
}
#[tokio::test]
async fn search_returns_matching_chunks() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value;
assert!(hits.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn read_chunk_row_returns_preview_and_metadata() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"phoenix migration scheduled friday with context and source refs",
)
.await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.id, chunk.id);
assert_eq!(row.source_kind, "chat");
assert_eq!(row.source_id, "slack:#eng");
assert_eq!(row.source_ref.as_deref(), Some("slack://x"));
assert_eq!(row.owner, "alice");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(row.content_path.is_some());
assert!(row
.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix migration scheduled friday"));
}
#[tokio::test]
async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() {
let (_tmp, cfg) = test_config();
let body = "sqlite preview survives missing file";
seed_chat_chunk(&cfg, "slack:#eng", body).await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let rel_path = chunk.content_path.clone().expect("content path present");
let abs_path = cfg.memory_tree_content_root().join(rel_path);
std::fs::remove_file(&abs_path).expect("remove chunk file");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.content_path, chunk.content_path);
assert!(row.content_preview.as_deref().unwrap_or("").contains(body));
}
#[tokio::test]
async fn flush_now_enqueues_once_and_reports_stale_buffers() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"Phoenix migration ships Friday after the release checklist closes.",
)
.await;
drain_until_idle(&cfg).await.expect("drain jobs");
let first = flush_now_rpc(&cfg).await.expect("flush_now first");
assert!(first.value.enqueued, "first flush should enqueue work");
assert!(
first.value.stale_buffers >= 1,
"expected at least one stale buffer after ingest"
);
let second = flush_now_rpc(&cfg).await.expect("flush_now second");
assert!(
!second.value.enqueued,
"same 3-hour window should dedupe duplicate flush triggers"
);
assert!(
second.value.stale_buffers >= 1,
"deduped flush should still report current stale buffer count"
);
}
#[tokio::test]
async fn reset_tree_preserves_raw_archive_and_source_registry() {
let (_tmp, cfg) = test_config();
let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await;
let content_root = cfg.memory_tree_content_root();
let raw_file = content_root
.join("raw")
.join("slack-conn-slack-1")
.join("chats")
.join("1700000000000_1700000000.000100.md");
let source_file = content_root
.join("raw")
.join("slack-conn-slack-1")
.join("_source.md");
assert!(raw_file.exists(), "raw archive should exist before reset");
assert!(
source_file.exists(),
"source registry should exist before reset"
);
let stale_summary = content_root
.join("wiki")
.join("summaries")
.join("source-slack-conn-slack-1")
.join("L1")
.join("summary-stale.md");
std::fs::create_dir_all(
stale_summary
.parent()
.expect("stale summary parent should exist"),
)
.expect("create stale summary dir");
std::fs::write(&stale_summary, "stale summary body").expect("write stale summary");
assert!(stale_summary.exists(), "stale summary fixture should exist");
let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree");
assert_eq!(outcome.value.chunks_requeued, 1);
assert_eq!(outcome.value.jobs_enqueued, 1);
assert!(
outcome.value.tree_rows_deleted >= 1,
"buffer/tree rows should be removed during reset"
);
let row = read_chunk_row(&cfg, &chunk_id)
.expect("read chunk row")
.expect("chunk row present after reset");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(raw_file.exists(), "raw archive must survive reset_tree");
assert!(
source_file.exists(),
"source registry must survive reset_tree"
);
assert!(
!content_root.join("wiki").join("summaries").exists(),
"derived wiki summaries should be removed"
);
}
#[test]
fn read_chunk_row_returns_none_for_missing_chunk() {
let (_tmp, cfg) = test_config();
assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none());
}
#[test]
fn display_name_unslugs_email_thread_with_user_hint() {
let name = display_name_for_source(
"gmail:alice@example.com|bob@example.com",
Some("alice@example.com"),
);
assert_eq!(name, "bob@example.com");
}
#[test]
fn display_name_falls_back_to_arrow_when_user_unknown() {
let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None);
assert!(name.contains("alice@example.com"));
assert!(name.contains("bob@example.com"));
assert!(name.contains(""));
}
#[test]
fn display_name_strips_platform_prefix() {
assert_eq!(
display_name_for_source("slack:#engineering", None),
"#engineering"
);
}
#[test]
fn display_name_handles_multiple_participants_and_trimmed_hint() {
let name = display_name_for_source(
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
Some(" alice@example.com "),
);
assert_eq!(name, "bob@example.com, carol@example.com");
}
#[test]
fn display_name_handles_no_prefix() {
assert_eq!(display_name_for_source("loose-id", None), "loose-id");
}
#[test]
fn sanitize_basename_replaces_windows_illegal_characters() {
assert_eq!(
sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#),
"chat-slack-#eng-name------"
);
assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md");
}
#[test]
fn parse_source_kind_str_accepts_known_values_only() {
assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat));
assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email));
assert_eq!(
parse_source_kind_str("document"),
Some(SourceKind::Document)
);
assert_eq!(parse_source_kind_str("unknown"), None);
}
#[test]
fn clear_composio_sync_state_removes_only_target_namespace() {
let tmp = TempDir::new().unwrap();
let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
let db_path = tmp.path().join("memory").join("memory.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES (?1, 'cursor', '{}', 1.0)",
params![KV_NAMESPACE],
)
.unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES ('other-namespace', 'cursor', '{}', 2.0)",
[],
)
.unwrap();
drop(conn);
let removed = clear_composio_sync_state(&db_path).unwrap();
assert_eq!(removed, 1);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let composio_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1",
params![KV_NAMESPACE],
|row| row.get(0),
)
.unwrap();
let other_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(composio_count, 0);
assert_eq!(other_count, 1);
}
#[tokio::test]
async fn obsidian_status_registered_when_override_config_lists_content_root() {
let (_tmp, cfg) = test_config();
let content_root = cfg.memory_tree_content_root();
// A separate dir standing in for a non-standard Obsidian config
// location, with an obsidian.json that registers the content root.
let cfg_dir = TempDir::new().unwrap();
let body = format!(
"{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}",
serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap()
);
std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(outcome.value.registered);
assert!(outcome.value.config_found);
assert_eq!(
outcome.value.content_root_abs,
content_root.to_string_lossy().to_string()
);
// The log reports the booleans but redacts the absolute path (it
// embeds the user's home / username).
assert!(
outcome.logs[0].contains("registered=true"),
"log: {}",
outcome.logs[0]
);
assert!(
!outcome.logs[0].contains(content_root.to_str().unwrap()),
"log leaked content root: {}",
outcome.logs[0]
);
}
#[tokio::test]
async fn obsidian_status_not_registered_for_empty_override_dir() {
let (_tmp, cfg) = test_config();
// Empty override dir → no obsidian.json there → content root is not a
// registered vault. (A temp content root can't be under any real host
// vault either, so this stays false regardless of the dev machine.)
let cfg_dir = TempDir::new().unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
#[tokio::test]
async fn obsidian_status_blank_override_is_treated_as_none() {
// A whitespace-only override must be normalized to None rather than
// resolving to "." and probing a stray local ./obsidian.json. The temp
// content root isn't under any real host vault, so this stays false.
let (_tmp, cfg) = test_config();
let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
@@ -218,7 +218,7 @@ pub async fn sync_status_rpc(
// runner: the existing pattern across this module is to assert factory
// dispatch + error wrapping rather than mock the upstream HTTP. The
// network-touching paths are smoke-tested upstream in
// `composio::client_tests` / `composio::ops_test` and the
// `composio::client_tests` / `composio::ops_tests` and the
// direct-mode-toggle test in `action_tool.rs`.
#[cfg(test)]
@@ -155,5 +155,5 @@ pub async fn load_or_default(toolkit: &str) -> UserScopePref {
}
#[cfg(test)]
#[path = "user_scopes_test.rs"]
#[path = "user_scopes_tests.rs"]
mod tests;
+1 -1
View File
@@ -30,7 +30,7 @@ pub mod types;
#[cfg(test)]
mod benchmarks;
#[cfg(test)]
mod integration_test;
mod integration_tests;
pub use drill_down::drill_down;
pub use fetch::fetch_leaves;
+1 -1
View File
@@ -14,7 +14,7 @@ pub mod types;
pub mod decision_log;
#[cfg(test)]
mod integration_test;
mod integration_tests;
pub use engine::SubconsciousEngine;
pub use reflection::{Reflection, ReflectionKind, MAX_REFLECTIONS_PER_TICK};
+1 -1
View File
@@ -495,5 +495,5 @@ fn title_from_function(function: &str) -> String {
}
#[cfg(test)]
#[path = "ops_test.rs"]
#[path = "ops_tests.rs"]
mod tests;
+1 -1
View File
@@ -25,7 +25,7 @@ mod ops;
pub mod wechat_ingest;
#[cfg(test)]
#[path = "wechat_ingest_test.rs"]
#[path = "wechat_ingest_tests.rs"]
mod tests;
pub use ops::detect_webview_logins;