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

801 lines
25 KiB
Rust

use nomifun_db::{
ConversationFilters, ConversationRowUpdate, IConversationRepository, MessageRowUpdate, SortOrder,
SqliteConversationRepository, init_database_memory, models::ConversationRow, models::MessageRow,
};
const USER_ID: &str = "system_default_user";
async fn setup() -> (SqliteConversationRepository, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let repo = SqliteConversationRepository::new(db.pool().clone());
(repo, db)
}
fn make_conversation(suffix: &str) -> ConversationRow {
let now = nomifun_common::now_ms();
ConversationRow {
// id is allocated by SQLite on create(); the value here is ignored.
id: 0,
user_id: USER_ID.to_string(),
name: format!("Conversation {suffix}"),
r#type: "gemini".to_string(),
extra: r#"{"workspace":"/home/user/project"}"#.to_string(),
model: Some(r#"{"providerId":"prov_1","model":"claude-sonnet-4-20250514"}"#.to_string()),
status: Some("pending".to_string()),
source: Some("nomifun".to_string()),
channel_chat_id: None,
pinned: false,
pinned_at: None,
cron_job_id: None,
created_at: now,
updated_at: now,
}
}
fn make_message(conv_id: i64, content: &str) -> MessageRow {
let now = nomifun_common::now_ms();
MessageRow {
id: nomifun_common::generate_prefixed_id("msg"),
conversation_id: conv_id,
msg_id: Some(nomifun_common::generate_prefixed_id("cmsg")),
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,
}
}
fn make_artifact(conv_id: i64) -> nomifun_db::ConversationArtifactRow {
nomifun_db::ConversationArtifactRow {
// id is ignored on upsert (INTEGER PK AUTOINCREMENT); any value works.
id: 0,
conversation_id: conv_id,
cron_job_id: Some("cron_1".to_string()),
kind: "skill_suggest".to_string(),
status: "pending".to_string(),
payload: serde_json::json!({
"cron_job_id": "cron_1",
"name": "daily-report",
"description": "Daily report",
"skillContent": "---\nname: daily-report\n---\nUse it."
})
.to_string(),
created_at: 1000,
updated_at: 1000,
}
}
/// Insert a minimal `cron_jobs` row so the artifact FK
/// (`conversation_artifacts.cron_job_id → cron_jobs(id)`) is satisfiable.
/// conversation_id is left NULL to avoid an extra FK dependency.
async fn seed_cron_job(pool: &sqlx::SqlitePool, id: &str) {
sqlx::query(
"INSERT INTO cron_jobs \
(id, name, schedule_kind, schedule_value, payload_message, agent_type, created_by, created_at, updated_at) \
VALUES (?, 'Job', 'every', '60000', 'msg', 'acp', 'user', 0, 0)",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
// ── Conversation CRUD ───────────────────────────────────────────────
#[tokio::test]
async fn create_get_update_delete_lifecycle() {
let (repo, _db) = setup().await;
// Create
let mut conv = make_conversation("lifecycle");
conv.id = repo.create(&conv).await.unwrap();
// Get
let found = repo.get(conv.id).await.unwrap().unwrap();
assert_eq!(found.name, "Conversation lifecycle");
assert_eq!(found.status.as_deref(), Some("pending"));
// Update
let now = nomifun_common::now_ms();
repo.update(
conv.id,
&ConversationRowUpdate {
name: Some("Updated Name".to_string()),
status: Some("running".to_string()),
updated_at: Some(now),
..Default::default()
},
)
.await
.unwrap();
let updated = repo.get(conv.id).await.unwrap().unwrap();
assert_eq!(updated.name, "Updated Name");
assert_eq!(updated.status.as_deref(), Some("running"));
// Delete
repo.delete(conv.id).await.unwrap();
assert!(repo.get(conv.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_conversation_cascades_messages() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("cascade");
conv.id = repo.create(&conv).await.unwrap();
// Insert messages
for i in 0..3 {
let msg = make_message(conv.id, &format!("msg {i}"));
repo.insert_message(&msg).await.unwrap();
}
// Verify messages exist
let msgs = repo.get_messages(conv.id, 1, 50, SortOrder::Desc).await.unwrap();
assert_eq!(msgs.total, 3);
// Delete conversation → messages cascade
repo.delete(conv.id).await.unwrap();
let msgs = repo.get_messages(conv.id, 1, 50, SortOrder::Desc).await.unwrap();
assert_eq!(msgs.total, 0);
}
// ── Cursor pagination ───────────────────────────────────────────────
#[tokio::test]
async fn cursor_pagination_walks_through_all_items() {
let (repo, _db) = setup().await;
// Create 7 conversations with distinct updated_at
for i in 0..7 {
let mut c = make_conversation(&format!("{i}"));
c.updated_at = (i + 1) as i64 * 1000;
repo.create(&c).await.unwrap();
}
// Page 1: no cursor, limit 3
let p1 = repo
.list_paginated(
USER_ID,
&ConversationFilters {
limit: 3,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(p1.items.len(), 3);
assert!(p1.has_more);
assert_eq!(p1.total, 7);
// Page 2
let cursor = p1.items.last().unwrap().id.clone();
let p2 = repo
.list_paginated(
USER_ID,
&ConversationFilters {
cursor: Some(cursor),
limit: 3,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(p2.items.len(), 3);
assert!(p2.has_more);
// Page 3
let cursor = p2.items.last().unwrap().id.clone();
let p3 = repo
.list_paginated(
USER_ID,
&ConversationFilters {
cursor: Some(cursor),
limit: 3,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(p3.items.len(), 1);
assert!(!p3.has_more);
// All 7 items collected, no duplicates
let mut all_ids: Vec<_> = p1
.items
.iter()
.chain(p2.items.iter())
.chain(p3.items.iter())
.map(|c| c.id.clone())
.collect();
all_ids.sort();
all_ids.dedup();
assert_eq!(all_ids.len(), 7);
}
// ── Filter combinations ─────────────────────────────────────────────
#[tokio::test]
async fn filter_by_source_and_pinned_combined() {
let (repo, _db) = setup().await;
let mut c1 = make_conversation("nomifun-pinned");
c1.source = Some("nomifun".to_string());
c1.pinned = true;
c1.pinned_at = Some(nomifun_common::now_ms());
c1.id = repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("telegram-pinned");
c2.source = Some("telegram".to_string());
c2.pinned = true;
c2.pinned_at = Some(nomifun_common::now_ms());
repo.create(&c2).await.unwrap();
let mut c3 = make_conversation("nomifun-unpinned");
c3.source = Some("nomifun".to_string());
c3.pinned = false;
repo.create(&c3).await.unwrap();
// Filter: source=nomifun AND pinned=true
let result = repo
.list_paginated(
USER_ID,
&ConversationFilters {
source: Some("nomifun".to_string()),
pinned: Some(true),
limit: 20,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].id, c1.id);
}
#[tokio::test]
async fn filter_by_cron_job_id() {
let (repo, db) = setup().await;
seed_cron_job(db.pool(), "cron_123").await;
seed_cron_job(db.pool(), "cron_456").await;
let mut c1 = make_conversation("cron-a");
c1.cron_job_id = Some("cron_123".to_string());
c1.id = repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("cron-b");
c2.cron_job_id = Some("cron_456".to_string());
repo.create(&c2).await.unwrap();
let c3 = make_conversation("no-cron"); // cron_job_id is None
repo.create(&c3).await.unwrap();
let result = repo
.list_paginated(
USER_ID,
&ConversationFilters {
cron_job_id: Some("cron_123".to_string()),
limit: 20,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].id, c1.id);
}
// ── Extended queries ────────────────────────────────────────────────
#[tokio::test]
async fn find_by_source_and_chat_integration() {
let (repo, _db) = setup().await;
let mut c = make_conversation("telegram");
c.source = Some("telegram".to_string());
c.channel_chat_id = Some("group:789".to_string());
c.r#type = "acp".to_string();
c.id = repo.create(&c).await.unwrap();
let found = repo
.find_by_source_and_chat(USER_ID, "telegram", "group:789", "acp")
.await
.unwrap()
.unwrap();
assert_eq!(found.id, c.id);
}
#[tokio::test]
async fn list_by_cron_job_returns_matching() {
let (repo, db) = setup().await;
seed_cron_job(db.pool(), "job_x").await;
seed_cron_job(db.pool(), "job_y").await;
let mut c1 = make_conversation("cron1");
c1.cron_job_id = Some("job_x".to_string());
repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("cron2");
c2.cron_job_id = Some("job_x".to_string());
repo.create(&c2).await.unwrap();
let mut c3 = make_conversation("cron3");
c3.cron_job_id = Some("job_y".to_string());
repo.create(&c3).await.unwrap();
let result = repo.list_by_cron_job(USER_ID, "job_x").await.unwrap();
assert_eq!(result.len(), 2);
}
#[tokio::test]
async fn list_associated_finds_same_workspace() {
let (repo, _db) = setup().await;
let mut c1 = make_conversation("ws1");
c1.extra = r#"{"workspace":"/shared"}"#.to_string();
c1.id = repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("ws2");
c2.extra = r#"{"workspace":"/shared"}"#.to_string();
c2.id = repo.create(&c2).await.unwrap();
let mut c3 = make_conversation("ws3");
c3.extra = r#"{"workspace":"/different"}"#.to_string();
repo.create(&c3).await.unwrap();
let assoc = repo.list_associated(USER_ID, c1.id).await.unwrap();
assert_eq!(assoc.len(), 1);
assert_eq!(assoc[0].id, c2.id);
}
#[tokio::test]
async fn list_associated_returns_empty_when_no_workspace() {
let (repo, _db) = setup().await;
let mut c = make_conversation("no-ws");
c.extra = r#"{"setting":"value"}"#.to_string();
c.id = repo.create(&c).await.unwrap();
let assoc = repo.list_associated(USER_ID, c.id).await.unwrap();
assert!(assoc.is_empty());
}
// ── Message operations ──────────────────────────────────────────────
#[tokio::test]
async fn message_pagination_and_ordering() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("msgs");
conv.id = repo.create(&conv).await.unwrap();
for i in 0..10 {
let mut msg = make_message(conv.id, &format!("item {i}"));
msg.created_at = (i + 1) as i64 * 1000;
repo.insert_message(&msg).await.unwrap();
}
// DESC page 1
let p1 = repo.get_messages(conv.id, 1, 3, SortOrder::Desc).await.unwrap();
assert_eq!(p1.items.len(), 3);
assert_eq!(p1.total, 10);
assert!(p1.has_more);
assert!(p1.items[0].created_at > p1.items[1].created_at);
// ASC page 1
let asc = repo.get_messages(conv.id, 1, 3, SortOrder::Asc).await.unwrap();
assert!(asc.items[0].created_at < asc.items[1].created_at);
}
#[tokio::test]
async fn update_message_fields() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("msg-update");
conv.id = repo.create(&conv).await.unwrap();
let msg = make_message(conv.id, "original");
repo.insert_message(&msg).await.unwrap();
repo.update_message(
&msg.id,
&MessageRowUpdate {
content: Some(r#"{"content":"modified"}"#.to_string()),
hidden: Some(true),
status: Some(Some("error".to_string())),
},
)
.await
.unwrap();
let msgs = repo.get_messages(conv.id, 1, 50, SortOrder::Desc).await.unwrap();
let updated = &msgs.items[0];
assert_eq!(updated.content, r#"{"content":"modified"}"#);
assert!(updated.hidden);
assert_eq!(updated.status.as_deref(), Some("error"));
}
#[tokio::test]
async fn delete_messages_by_conversation_clears_all() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("msg-delete");
conv.id = repo.create(&conv).await.unwrap();
for i in 0..5 {
let msg = make_message(conv.id, &format!("msg {i}"));
repo.insert_message(&msg).await.unwrap();
}
repo.delete_messages_by_conversation(conv.id).await.unwrap();
let result = repo.get_messages(conv.id, 1, 50, SortOrder::Desc).await.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
}
#[tokio::test]
async fn get_message_by_msg_id_triple() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("msg-find");
conv.id = repo.create(&conv).await.unwrap();
let mut msg = make_message(conv.id, "findable");
msg.msg_id = Some("unique_msg_123".to_string());
msg.r#type = "tool_call".to_string();
repo.insert_message(&msg).await.unwrap();
// Match
let found = repo
.get_message_by_msg_id(conv.id, "unique_msg_123", "tool_call")
.await
.unwrap();
assert!(found.is_some());
// Wrong type → None
let not_found = repo
.get_message_by_msg_id(conv.id, "unique_msg_123", "text")
.await
.unwrap();
assert!(not_found.is_none());
// Wrong conv → None
let not_found = repo
.get_message_by_msg_id(999_999, "unique_msg_123", "tool_call")
.await
.unwrap();
assert!(not_found.is_none());
}
// ── Message search ──────────────────────────────────────────────────
#[tokio::test]
async fn search_messages_across_conversations() {
let (repo, _db) = setup().await;
let mut c1 = make_conversation("search1");
c1.id = repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("search2");
c2.id = repo.create(&c2).await.unwrap();
let msg1 = make_message(c1.id, "Rust 代码审查报告");
repo.insert_message(&msg1).await.unwrap();
let msg2 = make_message(c2.id, "Python 代码审查总结");
repo.insert_message(&msg2).await.unwrap();
let msg3 = make_message(c1.id, "unrelated content");
repo.insert_message(&msg3).await.unwrap();
let result = repo.search_messages(USER_ID, "审查", 1, 20).await.unwrap();
assert_eq!(result.total, 2);
assert_eq!(result.items.len(), 2);
// Verify conversation names are included
let names: Vec<_> = result.items.iter().map(|r| &r.conversation_name).collect();
assert!(names.contains(&&"Conversation search1".to_string()));
assert!(names.contains(&&"Conversation search2".to_string()));
}
#[tokio::test]
async fn search_messages_empty_result() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("empty-search");
conv.id = repo.create(&conv).await.unwrap();
let msg = make_message(conv.id, "hello world");
repo.insert_message(&msg).await.unwrap();
let result = repo
.search_messages(USER_ID, "nonexistent_keyword", 1, 20)
.await
.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total, 0);
assert!(!result.has_more);
}
#[tokio::test]
async fn search_messages_pagination() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("search-page");
conv.id = repo.create(&conv).await.unwrap();
for i in 0..5 {
let mut msg = make_message(conv.id, &format!("searchable item {i}"));
msg.created_at = (i + 1) as i64 * 1000;
repo.insert_message(&msg).await.unwrap();
}
let p1 = repo.search_messages(USER_ID, "searchable", 1, 2).await.unwrap();
assert_eq!(p1.items.len(), 2);
assert_eq!(p1.total, 5);
assert!(p1.has_more);
let p2 = repo.search_messages(USER_ID, "searchable", 2, 2).await.unwrap();
assert_eq!(p2.items.len(), 2);
assert!(p2.has_more);
let p3 = repo.search_messages(USER_ID, "searchable", 3, 2).await.unwrap();
assert_eq!(p3.items.len(), 1);
assert!(!p3.has_more);
}
// ── Pinned update flow ──────────────────────────────────────────────
#[tokio::test]
async fn pin_and_unpin_conversation() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("pin-test");
conv.id = repo.create(&conv).await.unwrap();
// Pin
let pin_time = nomifun_common::now_ms();
repo.update(
conv.id,
&ConversationRowUpdate {
pinned: Some(true),
pinned_at: Some(Some(pin_time)),
updated_at: Some(pin_time),
..Default::default()
},
)
.await
.unwrap();
let pinned = repo.get(conv.id).await.unwrap().unwrap();
assert!(pinned.pinned);
assert_eq!(pinned.pinned_at, Some(pin_time));
// Unpin
let now = nomifun_common::now_ms();
repo.update(
conv.id,
&ConversationRowUpdate {
pinned: Some(false),
pinned_at: Some(None),
updated_at: Some(now),
..Default::default()
},
)
.await
.unwrap();
let unpinned = repo.get(conv.id).await.unwrap().unwrap();
assert!(!unpinned.pinned);
assert!(unpinned.pinned_at.is_none());
}
// ── Error cases ─────────────────────────────────────────────────────
#[tokio::test]
async fn update_nonexistent_conversation_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo
.update(
999_999,
&ConversationRowUpdate {
name: Some("x".to_string()),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_nonexistent_conversation_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.delete(999_999).await.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
}
#[tokio::test]
async fn list_associated_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo.list_associated(USER_ID, 999_999).await.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
}
#[tokio::test]
async fn update_message_nonexistent_returns_not_found() {
let (repo, _db) = setup().await;
let err = repo
.update_message(
"nonexistent_id",
&MessageRowUpdate {
hidden: Some(true),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
}
// ── Extra field update ──────────────────────────────────────────────
#[tokio::test]
async fn update_extra_replaces_json() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("extra-update");
conv.id = repo.create(&conv).await.unwrap();
let now = nomifun_common::now_ms();
repo.update(
conv.id,
&ConversationRowUpdate {
extra: Some(r#"{"workspace":"/new","flag":true}"#.to_string()),
updated_at: Some(now),
..Default::default()
},
)
.await
.unwrap();
let found = repo.get(conv.id).await.unwrap().unwrap();
assert_eq!(found.extra, r#"{"workspace":"/new","flag":true}"#);
}
#[tokio::test]
async fn get_messages_excludes_legacy_cron_and_skill_suggest_rows() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("message-filter");
conv.id = repo.create(&conv).await.unwrap();
repo.insert_message(&make_message(conv.id, "visible")).await.unwrap();
for (id, ty) in [("legacy-cron", "cron_trigger"), ("legacy-skill", "skill_suggest")] {
repo.insert_message(&MessageRow {
id: id.into(),
conversation_id: conv.id.clone(),
msg_id: None,
r#type: ty.into(),
content: "{}".into(),
position: Some("center".into()),
status: Some("finish".into()),
hidden: false,
created_at: 2000,
})
.await
.unwrap();
}
let rows = repo.get_messages(conv.id, 1, 50, SortOrder::Asc).await.unwrap();
assert_eq!(rows.total, 1);
assert_eq!(rows.items.len(), 1);
assert_eq!(rows.items[0].r#type, "text");
}
#[tokio::test]
async fn list_legacy_cron_trigger_messages_returns_only_trigger_rows() {
let (repo, _db) = setup().await;
let mut conv = make_conversation("legacy-cron-trigger");
conv.id = repo.create(&conv).await.unwrap();
repo.insert_message(&MessageRow {
id: nomifun_common::generate_prefixed_id("msg"),
conversation_id: conv.id.clone(),
msg_id: Some("legacy-trigger".into()),
r#type: "cron_trigger".into(),
content: r#"{"cron_job_id":"cron_1","cron_job_name":"Daily Report"}"#.into(),
position: Some("center".into()),
status: Some("finish".into()),
hidden: false,
created_at: 1000,
})
.await
.unwrap();
repo.insert_message(&make_message(conv.id, "plain text"))
.await
.unwrap();
let rows = repo.list_legacy_cron_trigger_messages(conv.id).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].r#type, "cron_trigger");
}
#[tokio::test]
async fn artifact_upsert_list_and_mark_saved() {
let (repo, db) = setup().await;
let mut conv = make_conversation("artifact-row");
conv.id = repo.create(&conv).await.unwrap();
seed_cron_job(db.pool(), "cron_1").await;
let inserted = repo.upsert_artifact(&make_artifact(conv.id)).await.unwrap();
assert_eq!(inserted.status, "pending");
let artifact_id = inserted.id;
let listed = repo.list_artifacts(conv.id).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, artifact_id);
let dismissed = repo
.update_artifact_status(conv.id, artifact_id, "dismissed", 2000)
.await
.unwrap()
.unwrap();
assert_eq!(dismissed.status, "dismissed");
assert_eq!(dismissed.updated_at, 2000);
let saved = repo.mark_skill_suggest_artifacts_saved("cron_1", 3000).await.unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].status, "saved");
assert_eq!(saved[0].updated_at, 3000);
}
#[tokio::test]
async fn delete_artifacts_by_conversation_removes_rows() {
let (repo, db) = setup().await;
let mut conv = make_conversation("artifact-delete");
conv.id = repo.create(&conv).await.unwrap();
seed_cron_job(db.pool(), "cron_1").await;
repo.upsert_artifact(&make_artifact(conv.id)).await.unwrap();
repo.delete_artifacts_by_conversation(conv.id).await.unwrap();
let listed = repo.list_artifacts(conv.id).await.unwrap();
assert!(listed.is_empty());
}
// ── User isolation ──────────────────────────────────────────────────
#[tokio::test]
async fn list_paginated_scoped_to_user() {
let (repo, db) = setup().await;
// Create a second user
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_2', 'other', 'hash', 1000, 1000)",
)
.execute(db.pool())
.await
.unwrap();
let c1 = make_conversation("user1-conv");
repo.create(&c1).await.unwrap();
let mut c2 = make_conversation("user2-conv");
c2.user_id = "user_2".to_string();
repo.create(&c2).await.unwrap();
// User 1 only sees their own
let result = repo
.list_paginated(
USER_ID,
&ConversationFilters {
limit: 20,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].user_id, USER_ID);
}