Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-conversation/tests/conversation_extended.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

659 lines
21 KiB
Rust

use std::sync::Arc;
use nomifun_ai_agent::IWorkerTaskManager;
use nomifun_api_types::{
CloneConversationRequest, CreateConversationRequest, ListMessagesQuery, SearchMessagesQuery, WebSocketMessage,
};
use nomifun_common::{AgentKillReason, AppError, ConversationStatus, TimestampMs, generate_prefixed_id, now_ms};
use nomifun_conversation::ConversationService;
use nomifun_conversation::skill_resolver::SkillResolver;
use nomifun_db::models::MessageRow;
use nomifun_db::{IConversationRepository, SqliteConversationRepository, init_database_memory};
use nomifun_realtime::EventBroadcaster;
use serde_json::json;
use std::sync::Mutex;
// ── Test infrastructure ────────────────────────────────────────────
struct TestBroadcaster {
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl TestBroadcaster {
fn new() -> Self {
Self {
events: Mutex::new(vec![]),
}
}
}
impl EventBroadcaster for TestBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
struct NoopTaskManager;
#[async_trait::async_trait]
impl IWorkerTaskManager for NoopTaskManager {
fn get_task(&self, _: &str) -> Option<nomifun_ai_agent::AgentInstance> {
None
}
async fn get_or_build_task(
&self,
_: &str,
_: nomifun_ai_agent::types::BuildTaskOptions,
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
Err(AppError::Internal("noop".into()))
}
fn kill(&self, _: &str, _: Option<AgentKillReason>) -> Result<(), AppError> {
Ok(())
}
fn kill_and_wait(
&self,
_: &str,
_: Option<AgentKillReason>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
Box::pin(std::future::ready(()))
}
fn clear(&self) {}
fn active_count(&self) -> usize {
0
}
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
vec![]
}
}
struct EmptySkillResolver;
#[async_trait::async_trait]
impl SkillResolver for EmptySkillResolver {
async fn auto_inject_names(&self) -> Vec<String> {
Vec::new()
}
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_extension::ResolvedAgentSkill> {
Vec::new()
}
async fn link_workspace_skills(
&self,
_workspace: &std::path::Path,
_rel_dirs: &[&str],
_skills: &[nomifun_extension::ResolvedAgentSkill],
) -> usize {
0
}
}
async fn setup() -> (
ConversationService,
Arc<SqliteConversationRepository>,
Arc<TestBroadcaster>,
) {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
let broadcaster = Arc::new(TestBroadcaster::new());
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone()));
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
let svc = ConversationService::new(
std::env::temp_dir(),
broadcaster.clone(),
Arc::new(EmptySkillResolver),
task_mgr,
repo.clone(),
agent_metadata_repo,
acp_session_repo,
);
(svc, repo, broadcaster)
}
const USER_ID: &str = "system_default_user";
fn make_create_req() -> CreateConversationRequest {
serde_json::from_value(json!({
"type": "acp",
"extra": { "workspace": "/home/user/project" }
}))
.unwrap()
}
fn make_message(conv_id: i64, content: &str, offset_ms: i64) -> MessageRow {
MessageRow {
id: generate_prefixed_id("msg"),
conversation_id: conv_id,
msg_id: Some(generate_prefixed_id("client")),
r#type: "text".to_string(),
content: format!(r#"{{"content":"{content}"}}"#),
position: Some("right".to_string()),
status: Some("finish".to_string()),
hidden: false,
created_at: now_ms() + offset_ms,
}
}
fn make_acp_tool_message(conv_id: i64, id: &str, output: &str, offset_ms: i64) -> MessageRow {
MessageRow {
id: id.to_string(),
conversation_id: conv_id,
msg_id: Some(id.to_string()),
r#type: "acp_tool_call".to_string(),
content: json!({
"session_id": "session-1",
"update": {
"session_update": "tool_call",
"tool_call_id": id,
"status": "completed",
"title": "rg",
"kind": "search",
"raw_input": { "pattern": "needle", "path": "." },
"content": [{
"type": "content",
"content": { "type": "text", "text": output }
}]
}
})
.to_string(),
position: Some("left".to_string()),
status: Some("finish".to_string()),
hidden: false,
created_at: now_ms() + offset_ms,
}
}
// ── T6: Clone conversation ─────────────────────────────────────────
#[tokio::test]
async fn t6_2_clone_without_source() {
let (svc, _repo, _b) = setup().await;
let req: CloneConversationRequest = serde_json::from_value(json!({
"conversation": {
"type": "acp",
"name": "Direct",
"extra": {}
}
}))
.unwrap();
let resp = svc.clone_create(USER_ID, req).await.unwrap();
assert_eq!(resp.name, "Direct");
// No source to merge from — only the caller-provided CreateConversationRequest
// drives `extra`, so source-only keys (e.g. `contextFileName`) must not appear.
assert!(resp.extra.get("contextFileName").is_none());
}
// ── T7: Reset conversation ─────────────────────────────────────────
#[tokio::test]
async fn t7_1_reset_clears_messages_and_status() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
// Insert messages
for i in 0..3 {
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i))
.await
.unwrap();
}
svc.reset(USER_ID, &conv.id.to_string()).await.unwrap();
let fetched = svc.get(USER_ID, &conv.id.to_string()).await.unwrap();
assert_eq!(fetched.status, ConversationStatus::Pending);
let messages = svc
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
.await
.unwrap();
assert!(messages.items.is_empty());
assert_eq!(messages.total, 0);
}
#[tokio::test]
async fn t7_3_reset_not_found() {
let (svc, _repo, _b) = setup().await;
let err = svc.reset(USER_ID, "nonexistent").await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
}
// ── T8: Message list ───────────────────────────────────────────────
#[tokio::test]
async fn t8_1_empty_messages() {
let (svc, _repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
let result = svc
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
.await
.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
}
#[tokio::test]
async fn t8_2_pagination() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
for i in 0..10 {
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 100))
.await
.unwrap();
}
let query = ListMessagesQuery {
page: Some(1),
page_size: Some(3),
order: None,
content_mode: None,
cursor: None,
};
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
assert_eq!(result.items.len(), 3);
assert_eq!(result.total, 10);
assert!(result.has_more);
}
#[tokio::test]
async fn t8_3_asc_order_default() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
for i in 0..3 {
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
.await
.unwrap();
}
let result = svc
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
.await
.unwrap();
// ASC (default): oldest first
assert!(result.items[0].created_at <= result.items[1].created_at);
assert!(result.items[1].created_at <= result.items[2].created_at);
}
#[tokio::test]
async fn t8_4_asc_order() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
for i in 0..3 {
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
.await
.unwrap();
}
let query = ListMessagesQuery {
order: Some("ASC".into()),
..Default::default()
};
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
assert!(result.items[0].created_at <= result.items[1].created_at);
assert!(result.items[1].created_at <= result.items[2].created_at);
}
#[tokio::test]
async fn t8_5_conversation_not_found() {
let (svc, _repo, _b) = setup().await;
let err = svc
.list_messages(USER_ID, "nonexistent", ListMessagesQuery::default())
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
}
// ── T9: Message search ─────────────────────────────────────────────
#[tokio::test]
async fn t8_6_compact_mode_truncates_large_tool_content_only_for_list_response() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
let large_output = "match line\n".repeat(10_000);
repo.insert_message(&make_acp_tool_message(conv.id, "tool-big", &large_output, 0))
.await
.unwrap();
let full = svc
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
.await
.unwrap();
assert_eq!(
full.items[0].content["update"]["content"][0]["content"]["text"]
.as_str()
.unwrap(),
large_output
);
let compact = svc
.list_messages(
USER_ID,
&conv.id.to_string(),
ListMessagesQuery {
content_mode: Some("compact".into()),
..Default::default()
},
)
.await
.unwrap();
let compact_content = &compact.items[0].content;
let preview = compact_content["update"]["content"][0]["content"]["text"]
.as_str()
.unwrap();
assert!(compact_content["_compact"]["truncated"].as_bool().unwrap());
assert!(compact_content["_compact"]["original_size"].as_u64().unwrap() > preview.len() as u64);
assert!(preview.len() < large_output.len());
assert!(!preview.contains(&large_output));
}
#[tokio::test]
async fn t8_7_get_message_returns_full_tool_content_after_compact_list() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
let large_output = "wide rg output\n".repeat(10_000);
repo.insert_message(&make_acp_tool_message(conv.id, "tool-detail", &large_output, 0))
.await
.unwrap();
let _ = svc
.list_messages(
USER_ID,
&conv.id.to_string(),
ListMessagesQuery {
content_mode: Some("compact".into()),
..Default::default()
},
)
.await
.unwrap();
let detail = svc.get_message(USER_ID, &conv.id.to_string(), "tool-detail").await.unwrap();
assert_eq!(
detail.content["update"]["content"][0]["content"]["text"]
.as_str()
.unwrap(),
large_output
);
}
#[tokio::test]
async fn t9_1_keyword_match() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
repo.insert_message(&make_message(conv.id, "Rust review report", 0))
.await
.unwrap();
repo.insert_message(&make_message(conv.id, "Python test", 100))
.await
.unwrap();
let query = SearchMessagesQuery {
keyword: "review".into(),
page: None,
page_size: None,
};
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.total, 1);
let item = &result.items[0];
assert_eq!(item.message_type, "text");
assert!(item.message_created_at > 0);
assert!(item.preview_text.contains("Rust review report"));
assert_eq!(item.conversation.id, conv.id);
assert_eq!(item.conversation.name, conv.name);
assert_eq!(item.conversation.extra["workspace"], "/home/user/project");
}
#[tokio::test]
async fn t9_2_no_match() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
repo.insert_message(&make_message(conv.id, "hello world", 0))
.await
.unwrap();
let query = SearchMessagesQuery {
keyword: "xxxxnotexist".into(),
page: None,
page_size: None,
};
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
}
#[tokio::test]
async fn t9_3_search_pagination() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
for i in 0..5 {
repo.insert_message(&make_message(conv.id, &format!("match keyword item {i}"), i * 100))
.await
.unwrap();
}
let query = SearchMessagesQuery {
keyword: "keyword".into(),
page: Some(1),
page_size: Some(2),
};
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert_eq!(result.items.len(), 2);
assert_eq!(result.total, 5);
assert!(result.has_more);
}
#[tokio::test]
async fn t9_4_empty_keyword() {
let (svc, _repo, _b) = setup().await;
let query = SearchMessagesQuery {
keyword: "".into(),
page: None,
page_size: None,
};
let err = svc.search_messages(USER_ID, query).await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
}
#[tokio::test]
async fn t9_5_preview_text_extracts_from_json_content() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
let complex_msg = MessageRow {
id: generate_prefixed_id("msg"),
conversation_id: conv.id.clone(),
msg_id: None,
r#type: "text".to_string(),
content: r#"[{"type":"text","content":"Design document for search"},{"type":"text","content":"feature implementation"}]"#.to_string(),
position: Some("right".to_string()),
status: Some("finish".to_string()),
hidden: false,
created_at: now_ms(),
};
repo.insert_message(&complex_msg).await.unwrap();
let query = SearchMessagesQuery {
keyword: "search".into(),
page: None,
page_size: None,
};
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert_eq!(result.items.len(), 1);
let item = &result.items[0];
assert!(!item.preview_text.contains('{'));
assert!(!item.preview_text.contains('['));
assert!(item.preview_text.contains("Design document for search"));
assert!(item.preview_text.contains("feature implementation"));
}
#[tokio::test]
async fn t9_6_search_result_includes_conversation_model() {
let (svc, repo, _b) = setup().await;
// Search surfaces conversation.model only for nomi (the only type that
// carries a top-level model under the nomi-only rule).
let nomi_req: CreateConversationRequest = serde_json::from_value(json!({
"type": "nomi",
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" },
"extra": { "workspace": "/home/user/project" }
}))
.unwrap();
let conv = svc.create(USER_ID, nomi_req).await.unwrap();
repo.insert_message(&make_message(conv.id, "model test keyword", 0))
.await
.unwrap();
let query = SearchMessagesQuery {
keyword: "model test".into(),
page: None,
page_size: None,
};
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert_eq!(result.items.len(), 1);
let item = &result.items[0];
let model = item.conversation.model.as_ref().unwrap();
assert_eq!(model.provider_id, "p1");
assert_eq!(model.model, "claude-sonnet-4-20250514");
}
#[tokio::test]
async fn t9_7_search_does_not_leak_other_users_messages() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
repo.insert_message(&make_message(conv.id, "secret keyword data", 0))
.await
.unwrap();
let query = SearchMessagesQuery {
keyword: "secret".into(),
page: None,
page_size: None,
};
let result = svc.search_messages("other_user_id", query).await.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
}
// ── T10: Associated conversations ──────────────────────────────────
#[tokio::test]
async fn t10_1_same_workspace() {
let (svc, _repo, _b) = setup().await;
let req1: CreateConversationRequest = serde_json::from_value(json!({
"type": "acp",
"name": "Conv A",
"extra": { "workspace": "/shared/path" }
}))
.unwrap();
let conv1 = svc.create(USER_ID, req1).await.unwrap();
let req2: CreateConversationRequest = serde_json::from_value(json!({
"type": "acp",
"name": "Conv B",
"extra": { "workspace": "/shared/path" }
}))
.unwrap();
let conv2 = svc.create(USER_ID, req2).await.unwrap();
// Different workspace
let req3: CreateConversationRequest = serde_json::from_value(json!({
"type": "acp",
"name": "Conv C",
"extra": { "workspace": "/other/path" }
}))
.unwrap();
svc.create(USER_ID, req3).await.unwrap();
let associated = svc.list_associated(USER_ID, &conv1.id.to_string()).await.unwrap();
assert_eq!(associated.len(), 1);
assert_eq!(associated[0].id, conv2.id);
}
#[tokio::test]
async fn t10_2_no_associated() {
let (svc, _repo, _b) = setup().await;
let req: CreateConversationRequest = serde_json::from_value(json!({
"type": "acp",
"extra": { "workspace": "/unique/path" }
}))
.unwrap();
let conv = svc.create(USER_ID, req).await.unwrap();
let associated = svc.list_associated(USER_ID, &conv.id.to_string()).await.unwrap();
assert!(associated.is_empty());
}
#[tokio::test]
async fn t10_3_associated_not_found() {
let (svc, _repo, _b) = setup().await;
let err = svc.list_associated(USER_ID, "nonexistent").await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
}
// ── T12: Boundary scenarios ────────────────────────────────────────
#[tokio::test]
async fn t12_4_search_sql_injection() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
repo.insert_message(&make_message(conv.id, "safe content", 0))
.await
.unwrap();
let query = SearchMessagesQuery {
keyword: "'; DROP TABLE messages; --".into(),
page: None,
page_size: None,
};
// Should return empty results, not crash
let result = svc.search_messages(USER_ID, query).await.unwrap();
assert!(result.items.is_empty());
}
// ── Ownership cross-cutting ────────────────────────────────────────
#[tokio::test]
async fn messages_wrong_user_returns_not_found() {
let (svc, repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
repo.insert_message(&make_message(conv.id, "hello", 0)).await.unwrap();
let err = svc
.list_messages("other_user", &conv.id.to_string(), ListMessagesQuery::default())
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
}
#[tokio::test]
async fn reset_wrong_user_returns_not_found() {
let (svc, _repo, _b) = setup().await;
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
let err = svc.reset("other_user", &conv.id.to_string()).await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
}