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>>, } impl TestBroadcaster { fn new() -> Self { Self { events: Mutex::new(vec![]), } } } impl EventBroadcaster for TestBroadcaster { fn broadcast(&self, event: WebSocketMessage) { self.events.lock().unwrap().push(event); } } struct NoopTaskManager; #[async_trait::async_trait] impl IWorkerTaskManager for NoopTaskManager { fn get_task(&self, _: &str) -> Option { None } async fn get_or_build_task( &self, _: &str, _: nomifun_ai_agent::types::BuildTaskOptions, ) -> Result { Err(AppError::Internal("noop".into())) } fn kill(&self, _: &str, _: Option) -> Result<(), AppError> { Ok(()) } fn kill_and_wait( &self, _: &str, _: Option, ) -> std::pin::Pin + Send>> { Box::pin(std::future::ready(())) } fn clear(&self) {} fn active_count(&self) -> usize { 0 } fn collect_idle(&self, _: TimestampMs) -> Vec { vec![] } } struct EmptySkillResolver; #[async_trait::async_trait] impl SkillResolver for EmptySkillResolver { async fn auto_inject_names(&self) -> Vec { Vec::new() } async fn resolve_skills(&self, _names: &[String]) -> Vec { 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, Arc, ) { 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 = Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone())); let acp_session_repo: Arc = Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone())); let task_mgr: Arc = 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(_))); }