mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(rust): move large inline tests to files (#2987)
This commit is contained in:
@@ -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(®istry, "acct-1", 2);
|
||||
let survivor_tasks = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let task_b = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let survivor_tasks = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let task_b = insert_pending_tasks(®istry, "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(®istry, "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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(®istry, "acct-1", 2);
|
||||
let survivor_tasks = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let task_b = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let survivor_tasks = insert_pending_tasks(®istry, "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(®istry, "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(®istry, "acct-1", 2);
|
||||
let task_b = insert_pending_tasks(®istry, "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(®istry, "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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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
@@ -244,5 +244,5 @@ impl Provider for RouterProvider {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "router_test.rs"]
|
||||
mod router_test;
|
||||
#[path = "router_tests.rs"]
|
||||
mod router_tests;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -495,5 +495,5 @@ fn title_from_function(function: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_test.rs"]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user