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