f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
659 lines
21 KiB
Rust
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(_)));
|
|
}
|