Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,62 @@
use nomifun_db::models::AttachmentRow;
use nomifun_db::{IAttachmentRepository, SqliteAttachmentRepository, init_database_memory};
fn row(id: &str, requirement_id: i64, name: &str) -> AttachmentRow {
AttachmentRow {
id: id.into(),
requirement_id,
file_name: name.into(),
rel_path: format!("attachments/{requirement_id}/{id}.png"),
mime: "image/png".into(),
size_bytes: 123,
created_by: Some("user".into()),
created_at: 1,
}
}
/// Insert a minimal `requirements` row so attachment FK
/// (`attachments.requirement_id → requirements(id)`) is satisfiable. The
/// explicit integer id is a valid AUTOINCREMENT rowid. owner_session_id /
/// owner_kind are both left NULL to satisfy the
/// `(owner_session_id IS NULL) = (owner_kind IS NULL)` CHECK.
async fn seed_requirement(pool: &sqlx::SqlitePool, id: i64) {
sqlx::query(
"INSERT INTO requirements (id, title, tag, created_at, updated_at) \
VALUES (?, 'Req', 'default', 0, 0)",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn insert_list_get_delete_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo = SqliteAttachmentRepository::new(db.pool().clone());
seed_requirement(db.pool(), 1).await;
seed_requirement(db.pool(), 2).await;
repo.insert(&row("att_1", 1, "one.png")).await.unwrap();
repo.insert(&row("att_2", 1, "two.png")).await.unwrap();
repo.insert(&row("att_3", 2, "other.png")).await.unwrap();
let listed = repo.list_for_requirement(1).await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].id, "att_1", "oldest first");
assert_eq!(listed[1].id, "att_2");
let got = repo.get_by_id("att_1").await.unwrap().expect("att_1 exists");
assert_eq!(got.file_name, "one.png");
assert_eq!(got.rel_path, "attachments/1/att_1.png");
assert!(repo.delete("att_1").await.unwrap());
assert!(!repo.delete("att_1").await.unwrap(), "second delete is a no-op");
assert!(repo.get_by_id("att_1").await.unwrap().is_none());
assert_eq!(repo.list_for_requirement(1).await.unwrap().len(), 1);
// a requirement with no attachments returns nothing
seed_requirement(db.pool(), 3).await;
assert!(repo.list_for_requirement(3).await.unwrap().is_empty());
}
@@ -0,0 +1,318 @@
//! Black-box integration tests for `IChannelRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
//! Covers test-plan items: DC-1..DC-4, PC-1..PC-3, PG-2.
use std::sync::Arc;
use nomifun_db::models::{AssistantSessionRow, AssistantUserRow, ChannelPluginRow, PairingCodeRow};
use nomifun_db::{
DbError, IChannelRepository, SqliteChannelRepository, UpdatePluginStatusParams, init_database_memory,
};
async fn repo() -> (Arc<dyn IChannelRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let r = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
(r as Arc<dyn IChannelRepository>, db)
}
fn make_plugin(id: &str, plugin_type: &str) -> ChannelPluginRow {
let now = nomifun_common::now_ms();
ChannelPluginRow {
id: id.into(),
r#type: plugin_type.into(),
name: format!("{plugin_type} bot"),
enabled: false,
config: r#"{"credentials":{}}"#.into(),
status: None,
last_connected: None,
companion_id: None,
bot_key: None,
created_at: now,
updated_at: now,
}
}
fn make_user(id: &str, platform_uid: &str, platform: &str) -> AssistantUserRow {
let now = nomifun_common::now_ms();
AssistantUserRow {
id: id.into(),
platform_user_id: platform_uid.into(),
platform_type: platform.into(),
channel_id: Some(TEST_CHANNEL.into()),
display_name: Some(format!("User {id}")),
authorized_at: now,
last_active: None,
session_id: None,
}
}
/// All test sessions arrive through the same channel row unless a test
/// passes a different channel id explicitly.
const TEST_CHANNEL: &str = "tg-1";
/// Seeds an `assistant_plugins` row so `assistant_sessions.channel_id`
/// (FK → assistant_plugins(id), added in the seq/primary-key refactor) can
/// reference it. `channel_id` is the verbatim routing key matched in
/// `get_or_create_session`, so it cannot be nulled out without breaking the
/// reuse/isolation semantics these tests exercise — the parent row must exist
/// instead. Uses `bot_key: None` (via `make_plugin`) to avoid the partial
/// unique index on `bot_key`. Idempotent through the upsert path.
async fn seed_channel(repo: &Arc<dyn IChannelRepository>, id: &str) {
repo.upsert_plugin(&make_plugin(id, "telegram")).await.unwrap();
}
fn make_session(id: &str, user_id: &str, chat_id: &str) -> AssistantSessionRow {
let now = nomifun_common::now_ms();
AssistantSessionRow {
id: id.into(),
user_id: user_id.into(),
agent_type: "gemini".into(),
conversation_id: None,
workspace: None,
chat_id: Some(chat_id.into()),
channel_id: Some(TEST_CHANNEL.into()),
created_at: now,
last_activity: now,
}
}
fn make_pairing(code: &str, platform_uid: &str, expires_offset_ms: i64) -> PairingCodeRow {
let now = nomifun_common::now_ms();
PairingCodeRow {
code: code.into(),
platform_user_id: platform_uid.into(),
platform_type: "telegram".into(),
channel_id: None,
display_name: Some("Tester".into()),
requested_at: now,
expires_at: now + expires_offset_ms,
status: "pending".into(),
}
}
// ── Plugin integration tests ─────────────────────────────────────────
#[tokio::test]
async fn plugin_full_lifecycle() {
let (repo, _db) = repo().await;
// Empty initially.
assert!(repo.get_all_plugins().await.unwrap().is_empty());
// Create two plugins.
repo.upsert_plugin(&make_plugin("tg-1", "telegram")).await.unwrap();
repo.upsert_plugin(&make_plugin("lark-1", "lark")).await.unwrap();
assert_eq!(repo.get_all_plugins().await.unwrap().len(), 2);
// Update status.
repo.update_plugin_status(
"tg-1",
&UpdatePluginStatusParams {
status: Some("running".into()),
enabled: Some(true),
..Default::default()
},
)
.await
.unwrap();
let tg = repo.get_plugin("tg-1").await.unwrap().unwrap();
assert!(tg.enabled);
assert_eq!(tg.status.as_deref(), Some("running"));
// Delete one.
repo.delete_plugin("lark-1").await.unwrap();
assert_eq!(repo.get_all_plugins().await.unwrap().len(), 1);
}
// ── DC-3: Same platform user uniqueness constraint ───────────────────
#[tokio::test]
async fn dc3_duplicate_platform_user_rejected() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
repo.create_user(&make_user("u1", "tg_100", "telegram")).await.unwrap();
// Same platform_user_id + platform_type with different id.
let dup = make_user("u2", "tg_100", "telegram");
let err = repo.create_user(&dup).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
// ── DC-1: Revoke user cascade deletes sessions ───────────────────────
#[tokio::test]
async fn dc1_delete_user_cascades_sessions() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
repo.create_user(&make_user("u1", "tg_1", "telegram")).await.unwrap();
// Create two sessions for the user.
repo.get_or_create_session("u1", "chat-a", TEST_CHANNEL, &make_session("s1", "u1", "chat-a"))
.await
.unwrap();
repo.get_or_create_session("u1", "chat-b", TEST_CHANNEL, &make_session("s2", "u1", "chat-b"))
.await
.unwrap();
assert_eq!(repo.get_all_sessions().await.unwrap().len(), 2);
// Delete user → sessions cascade.
repo.delete_user("u1").await.unwrap();
assert!(repo.get_all_sessions().await.unwrap().is_empty());
}
// ── PC-1: Same user, different chatId → different sessions ───────────
#[tokio::test]
async fn pc1_same_user_different_chat_ids() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
repo.create_user(&make_user("u1", "tg_1", "telegram")).await.unwrap();
let s1 = repo
.get_or_create_session("u1", "chat-a", TEST_CHANNEL, &make_session("s1", "u1", "chat-a"))
.await
.unwrap();
let s2 = repo
.get_or_create_session("u1", "chat-b", TEST_CHANNEL, &make_session("s2", "u1", "chat-b"))
.await
.unwrap();
assert_ne!(s1.id, s2.id);
assert_eq!(repo.get_all_sessions().await.unwrap().len(), 2);
}
// ── PC-2: Different users, same chatId → different sessions ──────────
#[tokio::test]
async fn pc2_different_users_same_chat_id() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
repo.create_user(&make_user("u1", "tg_1", "telegram")).await.unwrap();
repo.create_user(&make_user("u2", "tg_2", "telegram")).await.unwrap();
let s1 = repo
.get_or_create_session("u1", "chat-x", TEST_CHANNEL, &make_session("s1", "u1", "chat-x"))
.await
.unwrap();
let s2 = repo
.get_or_create_session("u2", "chat-x", TEST_CHANNEL, &make_session("s2", "u2", "chat-x"))
.await
.unwrap();
assert_ne!(s1.id, s2.id);
}
// ── PC-3: Same user, same chatId → reuse session ─────────────────────
#[tokio::test]
async fn pc3_same_user_same_chat_reuses_session() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
repo.create_user(&make_user("u1", "tg_1", "telegram")).await.unwrap();
let s1 = repo
.get_or_create_session("u1", "chat-a", TEST_CHANNEL, &make_session("s1", "u1", "chat-a"))
.await
.unwrap();
// Second call with a different new_row id but same user+chat.
let s2 = repo
.get_or_create_session("u1", "chat-a", TEST_CHANNEL, &make_session("s999", "u1", "chat-a"))
.await
.unwrap();
assert_eq!(s1.id, s2.id);
// last_activity should be >= original.
assert!(s2.last_activity >= s1.last_activity);
}
// ── PG-2: Pairing code expires_at = requested_at + 600s ─────────────
#[tokio::test]
async fn pg2_pairing_code_expiry_is_10_minutes() {
let (repo, _db) = repo().await;
let pairing = make_pairing("123456", "tg_99", 600_000);
repo.create_pairing(&pairing).await.unwrap();
let found = repo.get_pairing_by_code("123456").await.unwrap().unwrap();
assert_eq!(found.expires_at - found.requested_at, 600_000);
}
// ── EC-1 / EC-2: Expired pairings cleaned up, valid ones preserved ──
#[tokio::test]
async fn expired_pairings_cleaned_up() {
let (repo, _db) = repo().await;
let now = nomifun_common::now_ms();
// Already expired.
repo.create_pairing(&make_pairing("111111", "tg_1", -1000))
.await
.unwrap();
// Still valid.
repo.create_pairing(&make_pairing("222222", "tg_2", 600_000))
.await
.unwrap();
let cleaned = repo.cleanup_expired_pairings(now).await.unwrap();
assert_eq!(cleaned, 1);
let expired = repo.get_pairing_by_code("111111").await.unwrap().unwrap();
assert_eq!(expired.status, "expired");
let valid = repo.get_pairing_by_code("222222").await.unwrap().unwrap();
assert_eq!(valid.status, "pending");
}
// ── Pairing status transitions ───────────────────────────────────────
#[tokio::test]
async fn pairing_approve_and_reject() {
let (repo, _db) = repo().await;
repo.create_pairing(&make_pairing("100001", "tg_a", 600_000))
.await
.unwrap();
repo.create_pairing(&make_pairing("100002", "tg_b", 600_000))
.await
.unwrap();
repo.update_pairing_status("100001", "approved").await.unwrap();
repo.update_pairing_status("100002", "rejected").await.unwrap();
// Neither should appear in pending list.
let pending = repo.get_pending_pairings().await.unwrap();
assert!(pending.is_empty());
assert_eq!(
repo.get_pairing_by_code("100001").await.unwrap().unwrap().status,
"approved"
);
assert_eq!(
repo.get_pairing_by_code("100002").await.unwrap().unwrap().status,
"rejected"
);
}
// ── User list ordered by authorized_at desc ──────────────────────────
#[tokio::test]
async fn users_ordered_by_authorized_at_desc() {
let (repo, _db) = repo().await;
seed_channel(&repo, TEST_CHANNEL).await;
let mut u1 = make_user("u1", "tg_1", "telegram");
u1.authorized_at = 1000;
repo.create_user(&u1).await.unwrap();
let mut u2 = make_user("u2", "tg_2", "telegram");
u2.authorized_at = 2000;
repo.create_user(&u2).await.unwrap();
let users = repo.get_all_users().await.unwrap();
assert_eq!(users.len(), 2);
assert_eq!(users[0].id, "u2"); // more recent first
assert_eq!(users[1].id, "u1");
}
@@ -0,0 +1,145 @@
//! Black-box integration tests for IClientPreferenceRepository.
//!
//! Tests exercise the public trait interface against an in-memory SQLite database.
use std::sync::Arc;
use nomifun_db::{IClientPreferenceRepository, SqliteClientPreferenceRepository, init_database_memory};
async fn repo() -> Arc<dyn IClientPreferenceRepository> {
let db = init_database_memory().await.unwrap();
Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))
}
// -- Empty state --
#[tokio::test]
async fn get_all_returns_empty_when_no_preferences() {
let r = repo().await;
assert!(r.get_all().await.unwrap().is_empty());
}
// -- Upsert and retrieval --
#[tokio::test]
async fn upsert_then_get_all_returns_inserted_entries() {
let r = repo().await;
r.upsert_batch(&[("theme", "\"dark\""), ("companion.size", "360")])
.await
.unwrap();
let prefs = r.get_all().await.unwrap();
assert_eq!(prefs.len(), 2);
let keys: Vec<&str> = prefs.iter().map(|p| p.key.as_str()).collect();
assert!(keys.contains(&"theme"));
assert!(keys.contains(&"companion.size"));
}
#[tokio::test]
async fn upsert_overwrites_existing_key() {
let r = repo().await;
r.upsert_batch(&[("k", "v1")]).await.unwrap();
r.upsert_batch(&[("k", "v2")]).await.unwrap();
let prefs = r.get_all().await.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].value, "v2");
}
#[tokio::test]
async fn upsert_empty_batch_is_noop() {
let r = repo().await;
r.upsert_batch(&[]).await.unwrap();
assert!(r.get_all().await.unwrap().is_empty());
}
// -- Filtered retrieval --
#[tokio::test]
async fn get_by_keys_returns_only_matching() {
let r = repo().await;
r.upsert_batch(&[("a", "1"), ("b", "2"), ("c", "3")]).await.unwrap();
let prefs = r.get_by_keys(&["a", "c"]).await.unwrap();
assert_eq!(prefs.len(), 2);
let keys: Vec<&str> = prefs.iter().map(|p| p.key.as_str()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"c"));
}
#[tokio::test]
async fn get_by_keys_omits_nonexistent() {
let r = repo().await;
r.upsert_batch(&[("x", "1")]).await.unwrap();
let prefs = r.get_by_keys(&["x", "ghost"]).await.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].key, "x");
}
#[tokio::test]
async fn get_by_keys_empty_input_returns_empty() {
let r = repo().await;
r.upsert_batch(&[("x", "1")]).await.unwrap();
let prefs = r.get_by_keys(&[]).await.unwrap();
assert!(prefs.is_empty());
}
// -- Deletion --
#[tokio::test]
async fn delete_keys_removes_specified_entries() {
let r = repo().await;
r.upsert_batch(&[("a", "1"), ("b", "2"), ("c", "3")]).await.unwrap();
r.delete_keys(&["a", "c"]).await.unwrap();
let prefs = r.get_all().await.unwrap();
assert_eq!(prefs.len(), 1);
assert_eq!(prefs[0].key, "b");
}
#[tokio::test]
async fn delete_nonexistent_keys_is_noop() {
let r = repo().await;
r.upsert_batch(&[("x", "1")]).await.unwrap();
r.delete_keys(&["ghost"]).await.unwrap();
assert_eq!(r.get_all().await.unwrap().len(), 1);
}
#[tokio::test]
async fn delete_empty_keys_is_noop() {
let r = repo().await;
r.upsert_batch(&[("x", "1")]).await.unwrap();
r.delete_keys(&[]).await.unwrap();
assert_eq!(r.get_all().await.unwrap().len(), 1);
}
// -- Value types --
#[tokio::test]
async fn stores_boolean_number_string_json_values() {
let r = repo().await;
r.upsert_batch(&[
("bool_key", "true"),
("num_key", "42"),
("str_key", "\"hello\""),
("null_key", "null"),
])
.await
.unwrap();
let prefs = r.get_all().await.unwrap();
assert_eq!(prefs.len(), 4);
let find = |k: &str| prefs.iter().find(|p| p.key == k).unwrap().value.as_str();
assert_eq!(find("bool_key"), "true");
assert_eq!(find("num_key"), "42");
assert_eq!(find("str_key"), "\"hello\"");
assert_eq!(find("null_key"), "null");
}
@@ -0,0 +1,800 @@
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);
}
@@ -0,0 +1,637 @@
use nomifun_db::{init_database_memory, models::ConversationRow, models::MessageRow};
use sqlx::Row;
// Helper: insert a test user and return their id.
async fn insert_test_user(pool: &sqlx::SqlitePool) -> String {
let id = "test_user_1";
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ($1, 'testuser', 'hash', 1000, 1000)",
)
.bind(id)
.execute(pool)
.await
.unwrap();
id.to_string()
}
// Helper: insert a test conversation and return its id. The explicit integer
// id is a valid AUTOINCREMENT rowid.
async fn insert_test_conversation(pool: &sqlx::SqlitePool, user_id: &str) -> i64 {
let id: i64 = 1;
sqlx::query(
"INSERT INTO conversations \
(id, user_id, name, type, extra, status, created_at, updated_at) \
VALUES ($1, $2, 'Test Chat', 'gemini', '{\"workspace\":\"/tmp\"}', 'pending', 1000, 1000)",
)
.bind(id)
.bind(user_id)
.execute(pool)
.await
.unwrap();
id
}
// -- Migration creates tables --
#[tokio::test]
async fn migration_creates_conversations_table() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 0, "conversations table should exist and be empty");
}
#[tokio::test]
async fn migration_creates_messages_table() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 0, "messages table should exist and be empty");
}
// -- Conversations table: column acceptance --
#[tokio::test]
async fn conversations_accepts_all_columns() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
sqlx::query(
"INSERT INTO conversations \
(id, user_id, name, type, extra, model, status, source, channel_chat_id, \
pinned, pinned_at, created_at, updated_at) \
VALUES ($1, $2, 'Full Chat', 'acp', '{\"backend\":\"claude\"}', \
'{\"providerId\":\"p1\",\"model\":\"claude-sonnet\"}', \
'running', 'telegram', 'user:123', 1, 1700000000000, 1000, 2000)",
)
.bind(10_i64)
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT * FROM conversations WHERE id = 10")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("name"), "Full Chat");
assert_eq!(row.get::<String, _>("type"), "acp");
assert_eq!(row.get::<String, _>("status"), "running");
assert_eq!(row.get::<String, _>("source"), "telegram");
assert_eq!(row.get::<String, _>("channel_chat_id"), "user:123");
assert_eq!(row.get::<i32, _>("pinned"), 1);
assert_eq!(row.get::<i64, _>("pinned_at"), 1700000000000);
}
// -- Conversations table: default values --
#[tokio::test]
async fn conversations_defaults() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES (11, $1, 'Default Chat', 'gemini', 'pending', 1000, 1000)",
)
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT extra, pinned, pinned_at, model, source FROM conversations WHERE id = 11")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(
row.get::<String, _>("extra"),
"{}",
"extra should default to empty JSON"
);
assert_eq!(row.get::<i32, _>("pinned"), 0, "pinned should default to 0");
assert!(
row.get::<Option<i64>, _>("pinned_at").is_none(),
"pinned_at should default to NULL"
);
assert!(
row.get::<Option<String>, _>("model").is_none(),
"model should default to NULL"
);
assert!(
row.get::<Option<String>, _>("source").is_none(),
"source should default to NULL"
);
}
// -- Conversations table: CHECK constraints --
#[tokio::test]
async fn conversations_status_check_constraint() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let result = sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES (12, $1, 'Bad', 'gemini', 'invalid_status', 1000, 1000)",
)
.bind(&user_id)
.execute(db.pool())
.await;
assert!(result.is_err(), "invalid status should violate CHECK constraint");
}
#[tokio::test]
async fn conversations_status_allows_valid_values() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
for (i, status) in ["pending", "running", "finished"].iter().enumerate() {
let id = 20_i64 + i as i64;
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES ($1, $2, 'Test', 'gemini', $3, 1000, 1000)",
)
.bind(id)
.bind(&user_id)
.bind(status)
.execute(db.pool())
.await
.unwrap_or_else(|e| panic!("status '{status}' should be valid: {e}"));
}
}
// -- FK constraint: user_id --
#[tokio::test]
async fn conversations_fk_user_id() {
let db = init_database_memory().await.unwrap();
let result = sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, status, created_at, updated_at) \
VALUES (30, 'nonexistent_user', 'Bad FK', 'gemini', 'pending', 1000, 1000)",
)
.execute(db.pool())
.await;
assert!(result.is_err(), "non-existent user_id should violate FK constraint");
}
// -- CASCADE delete: users → conversations --
#[tokio::test]
async fn cascade_delete_user_removes_conversations() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
insert_test_conversation(db.pool(), &user_id).await;
// Verify conversation exists
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations WHERE user_id = $1")
.bind(&user_id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 1);
// Delete user
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
// Conversations should be gone
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations WHERE user_id = $1")
.bind(&user_id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 0, "conversations should be cascade-deleted with user");
}
// -- Messages table: column acceptance --
#[tokio::test]
async fn messages_accepts_all_columns() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
sqlx::query(
"INSERT INTO messages \
(id, conversation_id, msg_id, type, content, position, status, hidden, created_at) \
VALUES ('msg_1', $1, 'client_msg_1', 'text', \
'{\"content\":\"Hello\"}', 'right', 'finish', 0, 1000)",
)
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT * FROM messages WHERE id = 'msg_1'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<i64, _>("conversation_id"), conv_id);
assert_eq!(row.get::<String, _>("msg_id"), "client_msg_1");
assert_eq!(row.get::<String, _>("type"), "text");
assert_eq!(row.get::<String, _>("position"), "right");
assert_eq!(row.get::<String, _>("status"), "finish");
assert_eq!(row.get::<i32, _>("hidden"), 0);
}
// -- Messages table: default values --
#[tokio::test]
async fn messages_defaults() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, created_at) \
VALUES ('msg_def', $1, 'text', 1000)",
)
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT content, hidden, msg_id, position, status FROM messages WHERE id = 'msg_def'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(
row.get::<String, _>("content"),
"{}",
"content should default to empty JSON"
);
assert_eq!(row.get::<i32, _>("hidden"), 0, "hidden should default to 0");
assert!(row.get::<Option<String>, _>("msg_id").is_none());
assert!(row.get::<Option<String>, _>("position").is_none());
assert!(row.get::<Option<String>, _>("status").is_none());
}
// -- Messages table: CHECK constraints --
#[tokio::test]
async fn messages_position_check_constraint() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
let result = sqlx::query(
"INSERT INTO messages (id, conversation_id, type, position, created_at) \
VALUES ('msg_bad_pos', $1, 'text', 'invalid_pos', 1000)",
)
.bind(&conv_id)
.execute(db.pool())
.await;
assert!(result.is_err(), "invalid position should violate CHECK constraint");
}
#[tokio::test]
async fn messages_status_check_constraint() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
let result = sqlx::query(
"INSERT INTO messages (id, conversation_id, type, status, created_at) \
VALUES ('msg_bad_st', $1, 'text', 'invalid_status', 1000)",
)
.bind(&conv_id)
.execute(db.pool())
.await;
assert!(result.is_err(), "invalid status should violate CHECK constraint");
}
#[tokio::test]
async fn messages_allows_valid_positions() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
for (i, pos) in ["left", "right", "center", "pop"].iter().enumerate() {
let id = format!("msg_p{}", i);
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, position, created_at) \
VALUES ($1, $2, 'text', $3, 1000)",
)
.bind(&id)
.bind(&conv_id)
.bind(pos)
.execute(db.pool())
.await
.unwrap_or_else(|e| panic!("position '{pos}' should be valid: {e}"));
}
}
#[tokio::test]
async fn messages_allows_valid_statuses() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
for (i, status) in ["finish", "pending", "error", "work"].iter().enumerate() {
let id = format!("msg_s{}", i);
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, status, created_at) \
VALUES ($1, $2, 'text', $3, 1000)",
)
.bind(&id)
.bind(&conv_id)
.bind(status)
.execute(db.pool())
.await
.unwrap_or_else(|e| panic!("status '{status}' should be valid: {e}"));
}
}
// -- FK constraint: conversation_id --
#[tokio::test]
async fn messages_fk_conversation_id() {
let db = init_database_memory().await.unwrap();
let result = sqlx::query(
"INSERT INTO messages (id, conversation_id, type, created_at) \
VALUES ('msg_fk', 999999, 'text', 1000)",
)
.execute(db.pool())
.await;
assert!(
result.is_err(),
"non-existent conversation_id should violate FK constraint"
);
}
// -- CASCADE delete: conversations → messages --
#[tokio::test]
async fn cascade_delete_conversation_removes_messages() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
// Insert messages
for i in 0..3 {
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, content, created_at) \
VALUES ($1, $2, 'text', '{\"content\":\"msg\"}', 1000)",
)
.bind(format!("msg_{}", i))
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
}
// Verify messages exist
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages WHERE conversation_id = $1")
.bind(&conv_id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 3);
// Delete conversation
sqlx::query("DELETE FROM conversations WHERE id = $1")
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
// Messages should be gone
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages WHERE conversation_id = $1")
.bind(&conv_id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count.0, 0, "messages should be cascade-deleted with conversation");
}
// -- Full cascade: users → conversations → messages --
#[tokio::test]
async fn cascade_delete_user_removes_conversations_and_messages() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, created_at) \
VALUES ('msg_cascade', $1, 'text', 1000)",
)
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
// Delete user — should cascade to conversations and messages
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let conv_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations")
.fetch_one(db.pool())
.await
.unwrap();
let msg_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(conv_count.0, 0, "conversations should be cascade-deleted");
assert_eq!(msg_count.0, 0, "messages should be cascade-deleted");
}
// -- FromRow: ConversationRow --
#[tokio::test]
async fn conversation_row_from_row() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
sqlx::query(
"INSERT INTO conversations \
(id, user_id, name, type, extra, model, status, source, channel_chat_id, \
pinned, pinned_at, created_at, updated_at) \
VALUES (40, $1, 'FromRow Test', 'gemini', '{\"workspace\":\"/home\"}', \
'{\"providerId\":\"p1\",\"model\":\"m1\"}', \
'finished', 'nomifun', 'group:42', 1, 1700000000000, 1000, 2000)",
)
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let row: ConversationRow = sqlx::query_as("SELECT * FROM conversations WHERE id = 40")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.id, 40);
assert_eq!(row.user_id, user_id);
assert_eq!(row.name, "FromRow Test");
assert_eq!(row.r#type, "gemini");
assert_eq!(row.extra, "{\"workspace\":\"/home\"}");
assert_eq!(row.model.as_deref(), Some("{\"providerId\":\"p1\",\"model\":\"m1\"}"));
assert_eq!(row.status.as_deref(), Some("finished"));
assert_eq!(row.source.as_deref(), Some("nomifun"));
assert_eq!(row.channel_chat_id.as_deref(), Some("group:42"));
assert!(row.pinned);
assert_eq!(row.pinned_at, Some(1700000000000));
assert_eq!(row.created_at, 1000);
assert_eq!(row.updated_at, 2000);
}
#[tokio::test]
async fn conversation_row_nullable_fields() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
sqlx::query(
"INSERT INTO conversations \
(id, user_id, name, type, extra, status, created_at, updated_at) \
VALUES (41, $1, 'Nullable Test', 'remote', '{}', 'pending', 1000, 1000)",
)
.bind(&user_id)
.execute(db.pool())
.await
.unwrap();
let row: ConversationRow = sqlx::query_as("SELECT * FROM conversations WHERE id = 41")
.fetch_one(db.pool())
.await
.unwrap();
assert!(row.model.is_none());
assert!(row.source.is_none());
assert!(row.channel_chat_id.is_none());
assert!(!row.pinned);
assert!(row.pinned_at.is_none());
}
// -- FromRow: MessageRow --
#[tokio::test]
async fn message_row_from_row() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
sqlx::query(
"INSERT INTO messages \
(id, conversation_id, msg_id, type, content, position, status, hidden, created_at) \
VALUES ('msg_fr', $1, 'client_42', 'text', '{\"content\":\"Hi\"}', \
'right', 'finish', 1, 1500)",
)
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
let row: MessageRow = sqlx::query_as("SELECT * FROM messages WHERE id = 'msg_fr'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.id, "msg_fr");
assert_eq!(row.conversation_id, conv_id);
assert_eq!(row.msg_id.as_deref(), Some("client_42"));
assert_eq!(row.r#type, "text");
assert_eq!(row.content, "{\"content\":\"Hi\"}");
assert_eq!(row.position.as_deref(), Some("right"));
assert_eq!(row.status.as_deref(), Some("finish"));
assert!(row.hidden);
assert_eq!(row.created_at, 1500);
}
#[tokio::test]
async fn message_row_nullable_fields() {
let db = init_database_memory().await.unwrap();
let user_id = insert_test_user(db.pool()).await;
let conv_id = insert_test_conversation(db.pool(), &user_id).await;
sqlx::query(
"INSERT INTO messages (id, conversation_id, type, created_at) \
VALUES ('msg_null', $1, 'tips', 2000)",
)
.bind(&conv_id)
.execute(db.pool())
.await
.unwrap();
let row: MessageRow = sqlx::query_as("SELECT * FROM messages WHERE id = 'msg_null'")
.fetch_one(db.pool())
.await
.unwrap();
assert!(row.msg_id.is_none());
assert!(row.position.is_none());
assert!(row.status.is_none());
assert!(!row.hidden);
assert_eq!(row.content, "{}");
}
// -- Index existence --
#[tokio::test]
async fn conversation_indexes_exist() {
let db = init_database_memory().await.unwrap();
let indexes: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master \
WHERE type = 'index' AND tbl_name = 'conversations' AND name LIKE 'idx_%'",
)
.fetch_all(db.pool())
.await
.unwrap();
let names: Vec<&str> = indexes.iter().map(|r| r.0.as_str()).collect();
assert!(names.contains(&"idx_conversations_user_id"));
assert!(names.contains(&"idx_conversations_updated_at"));
assert!(names.contains(&"idx_conversations_type"));
assert!(names.contains(&"idx_conversations_user_updated"));
assert!(names.contains(&"idx_conversations_source"));
assert!(names.contains(&"idx_conversations_source_updated"));
}
#[tokio::test]
async fn message_indexes_exist() {
let db = init_database_memory().await.unwrap();
let indexes: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master \
WHERE type = 'index' AND tbl_name = 'messages' AND name LIKE 'idx_%'",
)
.fetch_all(db.pool())
.await
.unwrap();
let names: Vec<&str> = indexes.iter().map(|r| r.0.as_str()).collect();
assert!(names.contains(&"idx_messages_conversation_id"));
assert!(names.contains(&"idx_messages_created_at"));
assert!(names.contains(&"idx_messages_type"));
assert!(names.contains(&"idx_messages_msg_id"));
assert!(names.contains(&"idx_messages_conv_created"));
}
@@ -0,0 +1,409 @@
//! Black-box integration tests for `ICronRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
//!
//! Covers test-plan items from Phase 12 test-plan:
//! - Section A (CRUD): CJ-1..CJ-12 (data-layer portion)
//! - Section C (Skill): SK-1..SK-7 (data-layer portion)
//! - Section D (Schedule Calculation): SC-1..SC-8 (data-layer portion)
//! - Section H (Cascade Delete): CD-1 (data-layer portion)
use std::sync::Arc;
use nomifun_common::now_ms;
use nomifun_db::models::CronJobRow;
use nomifun_db::{DbError, ICronRepository, SqliteCronRepository, UpdateCronJobParams, init_database_memory};
async fn repo() -> (Arc<dyn ICronRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (1, 'user_1', 'Conv 1', 'normal', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
let r = Arc::new(SqliteCronRepository::new(db.pool().clone()));
(r as Arc<dyn ICronRepository>, db)
}
fn make_job(id: &str) -> CronJobRow {
let now = now_ms();
CronJobRow {
id: id.into(),
name: "Test Job".into(),
enabled: true,
schedule_kind: "every".into(),
schedule_value: "60000".into(),
schedule_tz: None,
schedule_description: Some("Every minute".into()),
payload_message: "Run report".into(),
execution_mode: "existing".into(),
agent_config: None,
conversation_id: Some(1),
conversation_title: Some("Conv 1".into()),
agent_type: "acp".into(),
created_by: "user".into(),
skill_content: None,
description: None,
created_at: now,
updated_at: now,
next_run_at: Some(now + 60_000),
last_run_at: None,
last_status: None,
last_error: None,
run_count: 0,
retry_count: 0,
max_retries: 3,
target_kind: "agent".into(),
terminal_mode: None,
terminal_session_id: None,
terminal_command: None,
terminal_args: None,
terminal_script: None,
}
}
// ── A. CRUD ──────────────────────────────────────────────────────────
#[tokio::test]
async fn cj1_insert_returns_all_fields() {
let (r, _db) = repo().await;
let job = make_job("cron_cj1");
r.insert(&job).await.unwrap();
let found = r.get_by_id("cron_cj1").await.unwrap().expect("found");
assert_eq!(found.id, "cron_cj1");
assert_eq!(found.name, "Test Job");
assert!(found.enabled);
assert_eq!(found.schedule_kind, "every");
assert_eq!(found.schedule_value, "60000");
assert_eq!(found.payload_message, "Run report");
assert_eq!(found.execution_mode, "existing");
assert_eq!(found.conversation_id, Some(1));
assert_eq!(found.agent_type, "acp");
assert_eq!(found.created_by, "user");
assert_eq!(found.run_count, 0);
assert_eq!(found.retry_count, 0);
assert_eq!(found.max_retries, 3);
}
#[tokio::test]
async fn cj2_three_schedule_kinds() {
let (r, _db) = repo().await;
let mut at_job = make_job("cron_at");
at_job.schedule_kind = "at".into();
at_job.schedule_value = "1700000000000".into();
r.insert(&at_job).await.unwrap();
let mut every_job = make_job("cron_every");
every_job.schedule_kind = "every".into();
every_job.schedule_value = "60000".into();
r.insert(&every_job).await.unwrap();
let mut cron_job = make_job("cron_cron");
cron_job.schedule_kind = "cron".into();
cron_job.schedule_value = "0 */5 * * * *".into();
cron_job.schedule_tz = Some("Asia/Shanghai".into());
r.insert(&cron_job).await.unwrap();
let at = r.get_by_id("cron_at").await.unwrap().unwrap();
assert_eq!(at.schedule_kind, "at");
let every = r.get_by_id("cron_every").await.unwrap().unwrap();
assert_eq!(every.schedule_kind, "every");
let cron = r.get_by_id("cron_cron").await.unwrap().unwrap();
assert_eq!(cron.schedule_kind, "cron");
assert_eq!(cron.schedule_tz.as_deref(), Some("Asia/Shanghai"));
}
#[tokio::test]
async fn cj4_get_by_id_existing() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_g1")).await.unwrap();
let found = r.get_by_id("cron_g1").await.unwrap();
assert!(found.is_some());
}
#[tokio::test]
async fn cj5_get_by_id_nonexistent() {
let (r, _db) = repo().await;
let found = r.get_by_id("cron_nonexistent").await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn cj6_list_all() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_l1")).await.unwrap();
r.insert(&make_job("cron_l2")).await.unwrap();
r.insert(&make_job("cron_l3")).await.unwrap();
let all = r.list_all().await.unwrap();
assert!(all.len() >= 3);
}
#[tokio::test]
async fn cj7_list_by_conversation() {
let (r, db) = repo().await;
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (2, 'user_1', 'Conv 2', 'normal', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
r.insert(&make_job("cron_fc1")).await.unwrap();
r.insert(&make_job("cron_fc2")).await.unwrap();
let mut other = make_job("cron_fc3");
other.conversation_id = Some(2);
r.insert(&other).await.unwrap();
let conv1 = r.list_by_conversation(1).await.unwrap();
assert_eq!(conv1.len(), 2);
let conv2 = r.list_by_conversation(2).await.unwrap();
assert_eq!(conv2.len(), 1);
}
#[tokio::test]
async fn cj8_update_name_and_enabled() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_u1")).await.unwrap();
let params = UpdateCronJobParams {
name: Some("Renamed".into()),
enabled: Some(false),
..Default::default()
};
r.update("cron_u1", &params).await.unwrap();
let updated = r.get_by_id("cron_u1").await.unwrap().unwrap();
assert_eq!(updated.name, "Renamed");
assert!(!updated.enabled);
assert!(updated.updated_at >= updated.created_at);
}
#[tokio::test]
async fn cj9_update_schedule_type() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_s1")).await.unwrap();
let params = UpdateCronJobParams {
schedule_kind: Some("cron".into()),
schedule_value: Some("0 0 9 * * *".into()),
schedule_tz: Some(Some("UTC".into())),
next_run_at: Some(Some(9999999)),
..Default::default()
};
r.update("cron_s1", &params).await.unwrap();
let updated = r.get_by_id("cron_s1").await.unwrap().unwrap();
assert_eq!(updated.schedule_kind, "cron");
assert_eq!(updated.schedule_value, "0 0 9 * * *");
assert_eq!(updated.schedule_tz.as_deref(), Some("UTC"));
assert_eq!(updated.next_run_at, Some(9999999));
}
#[tokio::test]
async fn cj10_update_nonexistent() {
let (r, _db) = repo().await;
let params = UpdateCronJobParams {
name: Some("x".into()),
..Default::default()
};
let err = r.update("cron_nope", &params).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn cj11_delete() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_d1")).await.unwrap();
r.delete("cron_d1").await.unwrap();
let found = r.get_by_id("cron_d1").await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn cj12_delete_nonexistent() {
let (r, _db) = repo().await;
let err = r.delete("cron_nope").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// ── List enabled ─────────────────────────────────────────────────────
#[tokio::test]
async fn list_enabled_filters_disabled_jobs() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_en1")).await.unwrap();
let mut disabled = make_job("cron_en2");
disabled.enabled = false;
r.insert(&disabled).await.unwrap();
let enabled = r.list_enabled().await.unwrap();
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].id, "cron_en1");
}
// ── C. Skill (data layer) ────────────────────────────────────────────
#[tokio::test]
async fn sk1_save_skill_content() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_sk1")).await.unwrap();
let params = UpdateCronJobParams {
skill_content: Some(Some("---\nname: test\n---\nDo something".into())),
..Default::default()
};
r.update("cron_sk1", &params).await.unwrap();
let updated = r.get_by_id("cron_sk1").await.unwrap().unwrap();
assert!(updated.skill_content.is_some());
assert!(updated.skill_content.unwrap().contains("Do something"));
}
#[tokio::test]
async fn sk2_has_skill_after_save() {
let (r, _db) = repo().await;
let mut job = make_job("cron_sk2");
job.skill_content = Some("---\nname: s\n---\ncontent".into());
r.insert(&job).await.unwrap();
let found = r.get_by_id("cron_sk2").await.unwrap().unwrap();
assert!(found.skill_content.is_some());
}
#[tokio::test]
async fn sk3_no_skill_by_default() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_sk3")).await.unwrap();
let found = r.get_by_id("cron_sk3").await.unwrap().unwrap();
assert!(found.skill_content.is_none());
}
#[tokio::test]
async fn sk7_delete_clears_skill() {
let (r, _db) = repo().await;
let mut job = make_job("cron_sk7");
job.skill_content = Some("content".into());
r.insert(&job).await.unwrap();
r.delete("cron_sk7").await.unwrap();
let found = r.get_by_id("cron_sk7").await.unwrap();
assert!(found.is_none());
}
// ── H. Cascade delete (data layer) ──────────────────────────────────
#[tokio::test]
async fn cd1_delete_by_conversation_removes_all() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_cd1")).await.unwrap();
r.insert(&make_job("cron_cd2")).await.unwrap();
let deleted = r.delete_by_conversation(1).await.unwrap();
assert_eq!(deleted, 2);
let remaining = r.list_all().await.unwrap();
assert!(remaining.is_empty());
}
#[tokio::test]
async fn delete_by_conversation_no_match_returns_zero() {
let (r, _db) = repo().await;
let deleted = r.delete_by_conversation(999).await.unwrap();
assert_eq!(deleted, 0);
}
// ── Execution state tracking ────────────────────────────────────────
#[tokio::test]
async fn update_execution_state() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_ex1")).await.unwrap();
let now = now_ms();
let params = UpdateCronJobParams {
last_run_at: Some(Some(now)),
last_status: Some(Some("ok".into())),
run_count: Some(1),
retry_count: Some(0),
next_run_at: Some(Some(now + 60_000)),
..Default::default()
};
r.update("cron_ex1", &params).await.unwrap();
let updated = r.get_by_id("cron_ex1").await.unwrap().unwrap();
assert_eq!(updated.last_run_at, Some(now));
assert_eq!(updated.last_status.as_deref(), Some("ok"));
assert_eq!(updated.run_count, 1);
assert_eq!(updated.retry_count, 0);
}
#[tokio::test]
async fn update_error_state() {
let (r, _db) = repo().await;
r.insert(&make_job("cron_err1")).await.unwrap();
let params = UpdateCronJobParams {
last_status: Some(Some("error".into())),
last_error: Some(Some("timeout after 30s".into())),
retry_count: Some(1),
..Default::default()
};
r.update("cron_err1", &params).await.unwrap();
let updated = r.get_by_id("cron_err1").await.unwrap().unwrap();
assert_eq!(updated.last_status.as_deref(), Some("error"));
assert_eq!(updated.last_error.as_deref(), Some("timeout after 30s"));
assert_eq!(updated.retry_count, 1);
}
// ── Agent config JSON ───────────────────────────────────────────────
#[tokio::test]
async fn insert_and_retrieve_agent_config() {
let (r, _db) = repo().await;
let mut job = make_job("cron_ag1");
job.agent_config = Some(r#"{"backend":"openai","name":"GPT-4","modelId":"gpt-4","workspace":"/home/user"}"#.into());
r.insert(&job).await.unwrap();
let found = r.get_by_id("cron_ag1").await.unwrap().unwrap();
let config = found.agent_config.unwrap();
assert!(config.contains("openai"));
assert!(config.contains("gpt-4"));
}
// ── new_conversation execution mode ─────────────────────────────────
#[tokio::test]
async fn insert_new_conversation_mode() {
let (r, _db) = repo().await;
let mut job = make_job("cron_nc1");
job.execution_mode = "new_conversation".into();
r.insert(&job).await.unwrap();
let found = r.get_by_id("cron_nc1").await.unwrap().unwrap();
assert_eq!(found.execution_mode, "new_conversation");
}
@@ -0,0 +1,416 @@
use nomifun_db::{init_database, init_database_memory};
use sqlx::Row;
// -- T1.1 Initialization --
#[tokio::test]
async fn init_creates_users_table() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(db.pool())
.await
.unwrap();
assert!(
count.0 >= 1,
"users table should exist and have at least the system user"
);
}
// -- T1.2 Pragma configuration --
#[tokio::test]
async fn pragma_foreign_keys_enabled() {
let db = init_database_memory().await.unwrap();
let row: (i64,) = sqlx::query_as("PRAGMA foreign_keys")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.0, 1, "foreign_keys should be ON");
}
#[tokio::test]
async fn pragma_busy_timeout() {
let db = init_database_memory().await.unwrap();
let row: (i64,) = sqlx::query_as("PRAGMA busy_timeout")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.0, 5000, "busy_timeout should be 5000ms");
}
#[tokio::test]
async fn pragma_journal_mode_wal_on_file() {
let dir = tempfile::tempdir().unwrap();
let db = init_database(&dir.path().join("test.db")).await.unwrap();
let row: (String,) = sqlx::query_as("PRAGMA journal_mode")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(
row.0.to_lowercase(),
"wal",
"journal_mode should be WAL for file-backed DB"
);
db.close().await;
}
// -- T1.3 Idempotent re-initialization --
#[tokio::test]
async fn idempotent_reinit_preserves_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
// First init + insert test data
let db = init_database(&path).await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u1', 'alice', 'hash123', 1000, 1000)",
)
.execute(db.pool())
.await
.unwrap();
db.close().await;
// Second init — data should persist
let db = init_database(&path).await.unwrap();
let row = sqlx::query("SELECT username FROM users WHERE id = 'u1'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("username"), "alice");
db.close().await;
}
// -- T1.4 Migrations --
#[tokio::test]
async fn migrations_applied() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "at least one migration should be applied");
}
// -- T1.5 System default user --
#[tokio::test]
async fn system_default_user_exists() {
let db = init_database_memory().await.unwrap();
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("id"), "system_default_user");
assert_eq!(row.get::<String, _>("username"), "admin");
assert_eq!(
row.get::<String, _>("password_hash"),
"",
"system user should have empty password hash"
);
}
#[tokio::test]
async fn system_user_has_valid_timestamps() {
let before = nomifun_common::now_ms();
let db = init_database_memory().await.unwrap();
let after = nomifun_common::now_ms();
let row = sqlx::query("SELECT created_at, updated_at FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
let created = row.get::<i64, _>("created_at");
let updated = row.get::<i64, _>("updated_at");
assert!(
created >= before && created <= after,
"created_at should be within test window"
);
assert!(
updated >= before && updated <= after,
"updated_at should be within test window"
);
}
// -- Schema validation --
#[tokio::test]
async fn users_table_accepts_all_columns() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users \
(id, username, email, password_hash, avatar_path, jwt_secret, created_at, updated_at, last_login) \
VALUES ('u1', 'testuser', 'test@example.com', 'hash', '/avatar.png', 'secret', 1000, 2000, 3000)",
)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT * FROM users WHERE id = 'u1'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("email"), "test@example.com");
assert_eq!(
row.get::<Option<String>, _>("avatar_path"),
Some("/avatar.png".to_string())
);
assert_eq!(row.get::<Option<String>, _>("jwt_secret"), Some("secret".to_string()));
assert_eq!(row.get::<Option<i64>, _>("last_login"), Some(3000));
}
#[tokio::test]
async fn username_unique_constraint() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u1', 'duplicate', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u2', 'duplicate', 'h', 1, 1)",
)
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate username should violate unique constraint");
}
#[tokio::test]
async fn email_unique_constraint() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, email, password_hash, created_at, updated_at) \
VALUES ('u1', 'user1', 'same@example.com', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO users (id, username, email, password_hash, created_at, updated_at) \
VALUES ('u2', 'user2', 'same@example.com', 'h', 1, 1)",
)
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate email should violate unique constraint");
}
// -- Corruption recovery --
#[tokio::test]
async fn corruption_recovery_creates_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
// Write invalid content to simulate corruption
std::fs::write(&path, b"not a valid sqlite database").unwrap();
let db = init_database(&path).await.unwrap();
// Recovered database should work
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "recovered DB should have system user");
// Backup file should exist
let has_backup = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains("backup"));
assert!(has_backup, "backup of corrupted file should exist");
db.close().await;
}
// -- Directory creation --
#[tokio::test]
async fn creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("nested").join("test.db");
let db = init_database(&path).await.unwrap();
assert!(path.exists(), "database file should be created in nested directory");
db.close().await;
}
// -- Pre-baseline rebuild (pre-launch convenience; removed before release) --
//
// The 2026-06-12 clean-baseline refactor squashed migrations 001021 into a
// single 001_baseline.sql, resetting the migration chain. A dev database
// carrying the old `_sqlx_migrations` history (mismatched checksum on
// version 1, applied versions 221 missing from the resolved set) must be
// renamed to `*.pre-baseline.bak` and rebuilt empty instead of failing fast.
#[tokio::test]
async fn pre_baseline_database_is_renamed_and_rebuilt() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
// Build a valid database, then forge a pre-baseline migration history:
// tamper the baseline checksum and record extra applied versions.
let db = init_database(&path).await.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = X'00'")
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
VALUES (21, 'entity seq', TRUE, X'00', 0)",
)
.execute(db.pool())
.await
.unwrap();
// Marker row that must NOT survive the rebuild.
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u_old', 'old_dev_user', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
db.close().await;
// Re-init: the version mismatch must trigger the rename-and-rebuild path.
let db = init_database(&path).await.unwrap();
let old_user: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = 'u_old'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(old_user.0, 0, "rebuilt DB must be empty (old data renamed aside)");
let system_user: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(system_user.0, 1, "rebuilt DB should have the system user");
let backup = dir.path().join("nomifun-backend.db.pre-baseline.bak");
assert!(backup.exists(), "old database should be preserved as .pre-baseline.bak");
db.close().await;
}
#[tokio::test]
async fn pre_baseline_rebuild_numbers_subsequent_backups() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
// Occupy the primary backup name so the rebuild has to pick a suffix.
std::fs::write(dir.path().join("nomifun-backend.db.pre-baseline.bak"), b"earlier backup").unwrap();
let db = init_database(&path).await.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = X'00'")
.execute(db.pool())
.await
.unwrap();
db.close().await;
let db = init_database(&path).await.unwrap();
let numbered = dir.path().join("nomifun-backend.db.pre-baseline.bak.1");
assert!(numbered.exists(), "second backup should get a numeric suffix");
db.close().await;
}
// -- Concurrent migrator regression (ELECTRON-1KK) --
//
// Repro for the Sentry secondary symptom: two processes opening the same
// SQLite DB on first start (e.g. Electron auto-update spawning the new
// version while the old one is still finalising shutdown, or
// `nomicore doctor` racing the server) both decide to apply the same
// migration version. sqlx-sqlite's lock()/unlock() are no-ops, so without
// the advisory file lock and retry-on-UNIQUE the slower process used to
// blow up with `UNIQUE constraint failed: _sqlx_migrations.version`.
//
// We use OS threads (not tokio::spawn) so each migrator runs on its own
// runtime — this matches the real "two processes" topology more closely
// than cooperative tasks would, and avoids the `&SqlitePool: Send` lifetime
// gymnastics that block tokio::spawn on this future.
#[test]
fn concurrent_init_database_does_not_panic_on_unique_conflict() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
let mut handles = Vec::new();
for _ in 0..8 {
let p = path.clone();
handles.push(std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move { init_database(&p).await })
}));
}
// Every thread must succeed — none should bubble up the UNIQUE-constraint
// error from `_sqlx_migrations`.
let mut errors = Vec::new();
for h in handles {
match h.join().expect("thread panicked") {
Ok(_db) => {}
Err(e) => errors.push(e.to_string()),
}
}
assert!(
errors.is_empty(),
"all parallel migrators should succeed, got errors: {errors:?}"
);
// All migrators converged on the same baseline schema with no duplicate
// `_sqlx_migrations` rows.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let db = init_database(&path).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "at least one migration should be recorded");
let dup: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM (SELECT version FROM _sqlx_migrations GROUP BY version HAVING COUNT(*) > 1)",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(dup.0, 0, "no duplicate versions should ever exist in _sqlx_migrations");
db.close().await;
});
// Lock file is created next to the DB and is harmless to leave behind.
let lock = path.with_file_name("nomifun-backend.db.migrate.lock");
assert!(lock.exists(), "advisory lock file should be present after migrate");
}
@@ -0,0 +1,558 @@
//! Black-box integration tests for `IMcpServerRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
use std::sync::Arc;
use nomifun_db::{
CreateMcpServerParams, DbError, IMcpServerRepository, SqliteMcpServerRepository, UpdateMcpServerParams,
init_database_memory,
};
async fn repo() -> (Arc<dyn IMcpServerRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let r = Arc::new(SqliteMcpServerRepository::new(db.pool().clone()));
(r as Arc<dyn IMcpServerRepository>, db)
}
fn stdio_params() -> CreateMcpServerParams<'static> {
CreateMcpServerParams {
name: "test-mcp",
description: Some("A test MCP server"),
enabled: false,
transport_type: "stdio",
transport_config: r#"{"command":"npx","args":["-y","test-server"]}"#,
tools: None,
original_json: Some(r#"{"name":"test-mcp"}"#),
builtin: false,
}
}
fn http_params() -> CreateMcpServerParams<'static> {
CreateMcpServerParams {
name: "http-mcp",
description: None,
enabled: true,
transport_type: "http",
transport_config: r#"{"url":"https://example.com/mcp"}"#,
tools: None,
original_json: None,
builtin: false,
}
}
fn sse_params() -> CreateMcpServerParams<'static> {
CreateMcpServerParams {
name: "sse-mcp",
description: Some("SSE transport server"),
enabled: false,
transport_type: "sse",
transport_config: r#"{"url":"https://example.com/sse","headers":{"Authorization":"Bearer xxx"}}"#,
tools: None,
original_json: None,
builtin: false,
}
}
// -- C-1/C-2/C-3: Create servers with different transport types --
#[tokio::test]
async fn create_stdio_server() {
let (r, _db) = repo().await;
let server = r.create(stdio_params()).await.unwrap();
assert!(server.id > 0);
assert_eq!(server.name, "test-mcp");
assert_eq!(server.description.as_deref(), Some("A test MCP server"));
assert!(!server.enabled);
assert_eq!(server.transport_type, "stdio");
assert!(server.transport_config.contains("npx"));
assert!(server.tools.is_none());
assert_eq!(server.last_test_status, "disconnected");
assert!(server.last_connected.is_none());
assert!(!server.builtin);
assert!(server.created_at > 0);
}
#[tokio::test]
async fn create_http_server() {
let (r, _db) = repo().await;
let server = r.create(http_params()).await.unwrap();
assert_eq!(server.transport_type, "http");
assert!(server.enabled);
assert!(server.transport_config.contains("example.com/mcp"));
}
#[tokio::test]
async fn create_sse_server() {
let (r, _db) = repo().await;
let server = r.create(sse_params()).await.unwrap();
assert_eq!(server.transport_type, "sse");
assert!(server.transport_config.contains("Bearer xxx"));
}
// -- C-4: Duplicate name returns conflict --
#[tokio::test]
async fn create_duplicate_name_returns_conflict() {
let (r, _db) = repo().await;
r.create(stdio_params()).await.unwrap();
let err = r.create(stdio_params()).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
// -- R-1/R-2: Get by ID --
#[tokio::test]
async fn find_by_id_returns_full_record() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
assert_eq!(found.name, "test-mcp");
assert_eq!(found.transport_type, "stdio");
assert_eq!(found.original_json.as_deref(), Some(r#"{"name":"test-mcp"}"#));
}
#[tokio::test]
async fn find_by_id_nonexistent_returns_none() {
let (r, _db) = repo().await;
assert!(r.find_by_id(999_999).await.unwrap().is_none());
}
// -- Find by name --
#[tokio::test]
async fn find_by_name_returns_matching_record() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let found = r.find_by_name("test-mcp").await.unwrap().unwrap();
assert_eq!(found.id, created.id);
}
#[tokio::test]
async fn find_by_name_nonexistent_returns_none() {
let (r, _db) = repo().await;
assert!(r.find_by_name("nope").await.unwrap().is_none());
}
// -- R-3/R-4: List servers --
#[tokio::test]
async fn list_empty_returns_empty_vec() {
let (r, _db) = repo().await;
let servers = r.list().await.unwrap();
assert!(servers.is_empty());
}
#[tokio::test]
async fn list_returns_all_ordered_by_created_at() {
let (r, _db) = repo().await;
let s1 = r.create(stdio_params()).await.unwrap();
let s2 = r.create(http_params()).await.unwrap();
let s3 = r.create(sse_params()).await.unwrap();
let all = r.list().await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, s1.id);
assert_eq!(all[1].id, s2.id);
assert_eq!(all[2].id, s3.id);
}
// -- U-1/U-2/U-3: Update fields --
#[tokio::test]
async fn update_name_only_preserves_other_fields() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let updated = r
.update(
created.id,
UpdateMcpServerParams {
name: Some("renamed-mcp"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "renamed-mcp");
assert_eq!(updated.transport_type, created.transport_type);
assert_eq!(updated.transport_config, created.transport_config);
assert_eq!(updated.enabled, created.enabled);
assert!(updated.updated_at >= created.updated_at);
}
#[tokio::test]
async fn update_transport_type_and_config() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let updated = r
.update(
created.id,
UpdateMcpServerParams {
transport_type: Some("http"),
transport_config: Some(r#"{"url":"https://new.example.com"}"#),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.transport_type, "http");
assert!(updated.transport_config.contains("new.example.com"));
}
#[tokio::test]
async fn update_description() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let updated = r
.update(
created.id,
UpdateMcpServerParams {
description: Some(Some("new desc")),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.description.as_deref(), Some("new desc"));
}
// -- U-4: Update nonexistent --
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r
.update(999_999, UpdateMcpServerParams::default())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// -- U-5: Name conflict on update --
#[tokio::test]
async fn update_name_to_existing_name_returns_conflict() {
let (r, _db) = repo().await;
r.create(stdio_params()).await.unwrap();
let s2 = r.create(http_params()).await.unwrap();
let err = r
.update(
s2.id,
UpdateMcpServerParams {
name: Some("test-mcp"),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
}
// -- Update: clear optional fields --
#[tokio::test]
async fn update_can_clear_optional_fields() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
assert!(created.description.is_some());
assert!(created.original_json.is_some());
let updated = r
.update(
created.id,
UpdateMcpServerParams {
description: Some(None),
original_json: Some(None),
..Default::default()
},
)
.await
.unwrap();
assert!(updated.description.is_none());
assert!(updated.original_json.is_none());
}
// -- Update persists --
#[tokio::test]
async fn update_persists_to_database() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
r.update(
created.id,
UpdateMcpServerParams {
enabled: Some(true),
..Default::default()
},
)
.await
.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert!(found.enabled);
}
// -- D-1/D-2/D-3: Delete --
#[tokio::test]
async fn delete_existing_removes_record() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
r.delete(created.id).await.unwrap();
assert!(r.find_by_id(created.id).await.unwrap().is_none());
let deleted = r.find_by_id_any(created.id).await.unwrap().unwrap();
assert!(deleted.deleted_at.is_some());
assert!(!deleted.enabled);
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r.delete(999_999).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_one_does_not_affect_others() {
let (r, _db) = repo().await;
let s1 = r.create(stdio_params()).await.unwrap();
let s2 = r.create(http_params()).await.unwrap();
r.delete(s1.id).await.unwrap();
let remaining = r.list().await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, s2.id);
}
#[tokio::test]
async fn list_by_ids_any_includes_soft_deleted_rows() {
let (r, _db) = repo().await;
let active = r.create(stdio_params()).await.unwrap();
let deleted = r.create(http_params()).await.unwrap();
r.delete(deleted.id).await.unwrap();
let rows = r
.list_by_ids_any(&[deleted.id, active.id])
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, deleted.id);
assert!(rows[0].deleted_at.is_some());
assert_eq!(rows[1].id, active.id);
assert!(rows[1].deleted_at.is_none());
}
// -- B-1/B-2/B-3: Batch upsert --
#[tokio::test]
async fn batch_upsert_creates_new_servers() {
let (r, _db) = repo().await;
let results = r
.batch_upsert(&[stdio_params(), http_params(), sse_params()])
.await
.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].name, "test-mcp");
assert_eq!(results[1].name, "http-mcp");
assert_eq!(results[2].name, "sse-mcp");
let all = r.list().await.unwrap();
assert_eq!(all.len(), 3);
}
#[tokio::test]
async fn batch_upsert_updates_existing_by_name() {
let (r, _db) = repo().await;
let existing = r.create(stdio_params()).await.unwrap();
assert!(!existing.enabled);
let results = r
.batch_upsert(&[
CreateMcpServerParams {
enabled: true,
description: Some("Updated via batch"),
..stdio_params()
},
http_params(),
])
.await
.unwrap();
assert_eq!(results.len(), 2);
// Existing updated: same ID, new values
assert_eq!(results[0].id, existing.id);
assert!(results[0].enabled);
assert_eq!(results[0].description.as_deref(), Some("Updated via batch"));
// New created
assert_eq!(results[1].name, "http-mcp");
}
#[tokio::test]
async fn batch_upsert_empty_list() {
let (r, _db) = repo().await;
let results = r.batch_upsert(&[]).await.unwrap();
assert!(results.is_empty());
}
// -- Status updates --
#[tokio::test]
async fn update_status_with_timestamp() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let ts = nomifun_common::now_ms();
r.update_status(created.id, "connected", Some(ts)).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.last_test_status, "connected");
assert_eq!(found.last_connected, Some(ts));
}
#[tokio::test]
async fn update_status_without_timestamp_preserves_existing() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let ts = nomifun_common::now_ms();
r.update_status(created.id, "connected", Some(ts)).await.unwrap();
r.update_status(created.id, "error", None).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.last_test_status, "error");
assert_eq!(found.last_connected, Some(ts));
}
#[tokio::test]
async fn update_status_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r.update_status(999_999, "connected", None).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// -- Tools updates --
#[tokio::test]
async fn update_tools_sets_json() {
let (r, _db) = repo().await;
let created = r.create(stdio_params()).await.unwrap();
let tools_json = r#"[{"name":"read_file","description":"Read a file"}]"#;
r.update_tools(created.id, Some(tools_json)).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.tools.as_deref(), Some(tools_json));
}
#[tokio::test]
async fn update_tools_clears_to_null() {
let (r, _db) = repo().await;
let created = r
.create(CreateMcpServerParams {
tools: Some(r#"[{"name":"tool"}]"#),
..stdio_params()
})
.await
.unwrap();
assert!(created.tools.is_some());
r.update_tools(created.id, None).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert!(found.tools.is_none());
}
#[tokio::test]
async fn update_tools_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r.update_tools(999_999, Some("[]")).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// -- Full CRUD lifecycle --
#[tokio::test]
async fn full_crud_lifecycle() {
let (r, _db) = repo().await;
// Create
let created = r.create(stdio_params()).await.unwrap();
assert_eq!(created.name, "test-mcp");
// Read
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
// Update
let updated = r
.update(
created.id,
UpdateMcpServerParams {
name: Some("renamed-mcp"),
enabled: Some(true),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "renamed-mcp");
assert!(updated.enabled);
// Find by new name
let by_name = r.find_by_name("renamed-mcp").await.unwrap().unwrap();
assert_eq!(by_name.id, created.id);
// Update status
r.update_status(created.id, "connected", Some(nomifun_common::now_ms()))
.await
.unwrap();
let after_status = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(after_status.last_test_status, "connected");
// Delete
r.delete(created.id).await.unwrap();
assert!(r.find_by_id(created.id).await.unwrap().is_none());
assert!(r.list().await.unwrap().is_empty());
}
// -- Builtin server --
#[tokio::test]
async fn create_builtin_server() {
let (r, _db) = repo().await;
let server = r
.create(CreateMcpServerParams {
name: "builtin-img",
builtin: true,
enabled: true,
..stdio_params()
})
.await
.unwrap();
assert!(server.builtin);
assert!(server.enabled);
}
@@ -0,0 +1,207 @@
//! Black-box integration tests for `IOAuthTokenRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
use std::sync::Arc;
use nomifun_db::{
DbError, IOAuthTokenRepository, SqliteOAuthTokenRepository, UpsertOAuthTokenParams, init_database_memory,
};
async fn repo() -> (Arc<dyn IOAuthTokenRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let r = Arc::new(SqliteOAuthTokenRepository::new(db.pool().clone()));
(r as Arc<dyn IOAuthTokenRepository>, db)
}
fn sample_params() -> UpsertOAuthTokenParams<'static> {
UpsertOAuthTokenParams {
server_url: "https://mcp.example.com",
access_token: "enc_access_token_123",
refresh_token: Some("enc_refresh_token_456"),
token_type: "bearer",
expires_at: Some(1700000000000),
}
}
// -- OA-1: Unauthenticated server --
#[tokio::test]
async fn get_by_url_nonexistent_returns_none() {
let (r, _db) = repo().await;
assert!(r.get_by_url("https://nope.com").await.unwrap().is_none());
}
// -- OA-2: Insert and retrieve --
#[tokio::test]
async fn upsert_insert_then_get_returns_token() {
let (r, _db) = repo().await;
let inserted = r.upsert(sample_params()).await.unwrap();
assert_eq!(inserted.server_url, "https://mcp.example.com");
assert_eq!(inserted.access_token, "enc_access_token_123");
assert_eq!(inserted.refresh_token.as_deref(), Some("enc_refresh_token_456"));
assert_eq!(inserted.token_type, "bearer");
assert_eq!(inserted.expires_at, Some(1700000000000));
assert!(inserted.created_at > 0);
let found = r.get_by_url("https://mcp.example.com").await.unwrap().unwrap();
assert_eq!(found.access_token, "enc_access_token_123");
}
// -- Upsert updates existing --
#[tokio::test]
async fn upsert_updates_existing_token() {
let (r, _db) = repo().await;
let original = r.upsert(sample_params()).await.unwrap();
let updated = r
.upsert(UpsertOAuthTokenParams {
server_url: "https://mcp.example.com",
access_token: "new_access_token",
refresh_token: None,
token_type: "bearer",
expires_at: Some(1800000000000),
})
.await
.unwrap();
assert_eq!(updated.server_url, original.server_url);
assert_eq!(updated.access_token, "new_access_token");
assert!(updated.refresh_token.is_none());
assert_eq!(updated.expires_at, Some(1800000000000));
// created_at preserved from original insert
assert_eq!(updated.created_at, original.created_at);
}
// -- Upsert without optional fields --
#[tokio::test]
async fn upsert_without_refresh_token_or_expires_at() {
let (r, _db) = repo().await;
let token = r
.upsert(UpsertOAuthTokenParams {
server_url: "https://simple.example.com",
access_token: "simple_token",
refresh_token: None,
token_type: "bearer",
expires_at: None,
})
.await
.unwrap();
assert!(token.refresh_token.is_none());
assert!(token.expires_at.is_none());
}
// -- OA-6: Delete existing --
#[tokio::test]
async fn delete_existing_token() {
let (r, _db) = repo().await;
r.upsert(sample_params()).await.unwrap();
r.delete("https://mcp.example.com").await.unwrap();
assert!(r.get_by_url("https://mcp.example.com").await.unwrap().is_none());
}
// -- OA-7: Delete idempotency (returns NotFound for nonexistent) --
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r.delete("https://nope.com").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// -- OA-3: List authenticated URLs --
#[tokio::test]
async fn list_authenticated_urls_empty() {
let (r, _db) = repo().await;
let urls = r.list_authenticated_urls().await.unwrap();
assert!(urls.is_empty());
}
#[tokio::test]
async fn list_authenticated_urls_returns_all() {
let (r, _db) = repo().await;
r.upsert(sample_params()).await.unwrap();
r.upsert(UpsertOAuthTokenParams {
server_url: "https://other.example.com",
access_token: "token2",
refresh_token: None,
token_type: "bearer",
expires_at: None,
})
.await
.unwrap();
let urls = r.list_authenticated_urls().await.unwrap();
assert_eq!(urls.len(), 2);
assert!(urls.contains(&"https://mcp.example.com".to_string()));
assert!(urls.contains(&"https://other.example.com".to_string()));
}
// -- Delete does not affect other tokens --
#[tokio::test]
async fn delete_one_does_not_affect_others() {
let (r, _db) = repo().await;
r.upsert(sample_params()).await.unwrap();
r.upsert(UpsertOAuthTokenParams {
server_url: "https://other.example.com",
access_token: "token2",
refresh_token: None,
token_type: "bearer",
expires_at: None,
})
.await
.unwrap();
r.delete("https://mcp.example.com").await.unwrap();
let urls = r.list_authenticated_urls().await.unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://other.example.com");
}
// -- Full lifecycle --
#[tokio::test]
async fn full_oauth_lifecycle() {
let (r, _db) = repo().await;
// Initially no tokens
assert!(r.list_authenticated_urls().await.unwrap().is_empty());
assert!(r.get_by_url("https://mcp.example.com").await.unwrap().is_none());
// Store token
let token = r.upsert(sample_params()).await.unwrap();
assert_eq!(token.access_token, "enc_access_token_123");
// Verify stored
let urls = r.list_authenticated_urls().await.unwrap();
assert_eq!(urls.len(), 1);
// Update token (refresh)
let refreshed = r
.upsert(UpsertOAuthTokenParams {
server_url: "https://mcp.example.com",
access_token: "refreshed_token",
refresh_token: Some("new_refresh"),
token_type: "bearer",
expires_at: Some(1900000000000),
})
.await
.unwrap();
assert_eq!(refreshed.access_token, "refreshed_token");
assert_eq!(refreshed.created_at, token.created_at);
// Logout (delete)
r.delete("https://mcp.example.com").await.unwrap();
assert!(r.list_authenticated_urls().await.unwrap().is_empty());
}
@@ -0,0 +1,270 @@
//! Black-box integration tests for IProviderRepository.
//!
//! Tests exercise the public trait interface against an in-memory SQLite database.
use std::sync::Arc;
use nomifun_db::{
CreateProviderParams, DbError, IProviderRepository, SqliteProviderRepository, UpdateProviderParams,
init_database_memory,
};
async fn repo() -> Arc<dyn IProviderRepository> {
let db = init_database_memory().await.unwrap();
Arc::new(SqliteProviderRepository::new(db.pool().clone()))
}
fn sample_params() -> CreateProviderParams<'static> {
CreateProviderParams {
id: None,
platform: "anthropic",
name: "Anthropic",
base_url: "https://api.anthropic.com",
api_key_encrypted: "enc_key_data",
models: r#"["claude-sonnet-4-20250514"]"#,
enabled: true,
capabilities: r#"[{"type":"text"}]"#,
context_limit: Some(200000),
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
}
}
// -- Empty state --
#[tokio::test]
async fn list_returns_empty_when_no_providers() {
let r = repo().await;
assert!(r.list().await.unwrap().is_empty());
}
// -- Create --
#[tokio::test]
async fn create_returns_provider_with_generated_id() {
let r = repo().await;
let p = r.create(sample_params()).await.unwrap();
assert!(!p.id.is_empty());
assert_eq!(p.platform, "anthropic");
assert_eq!(p.name, "Anthropic");
assert_eq!(p.base_url, "https://api.anthropic.com");
assert!(p.enabled);
assert_eq!(p.context_limit, Some(200000));
assert!(p.created_at > 0);
}
#[tokio::test]
async fn create_stores_json_fields_as_strings() {
let r = repo().await;
let p = r.create(sample_params()).await.unwrap();
assert_eq!(p.models, r#"["claude-sonnet-4-20250514"]"#);
assert_eq!(p.capabilities, r#"[{"type":"text"}]"#);
}
#[tokio::test]
async fn create_with_all_optional_fields() {
let r = repo().await;
let p = r
.create(CreateProviderParams {
model_protocols: Some(r#"{"m1":"openai"}"#),
model_enabled: Some(r#"{"m1":true}"#),
model_health: Some(r#"{"m1":{"status":"healthy"}}"#),
bedrock_config: Some(r#"{"region":"us-east-1"}"#),
..sample_params()
})
.await
.unwrap();
assert_eq!(p.model_protocols.as_deref(), Some(r#"{"m1":"openai"}"#));
assert_eq!(p.model_enabled.as_deref(), Some(r#"{"m1":true}"#));
assert!(p.model_health.is_some());
assert!(p.bedrock_config.is_some());
}
// -- Find by ID --
#[tokio::test]
async fn find_by_id_existing_returns_provider() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
let found = r.find_by_id(&created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
assert_eq!(found.name, "Anthropic");
}
#[tokio::test]
async fn find_by_id_nonexistent_returns_none() {
let r = repo().await;
assert!(r.find_by_id("no_such_id").await.unwrap().is_none());
}
// -- List --
#[tokio::test]
async fn list_returns_all_providers_in_creation_order() {
let r = repo().await;
let first = r.create(sample_params()).await.unwrap();
let second = r
.create(CreateProviderParams {
platform: "openai",
name: "OpenAI",
..sample_params()
})
.await
.unwrap();
let all = r.list().await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, first.id);
assert_eq!(all[1].id, second.id);
}
// -- Update --
#[tokio::test]
async fn update_partial_fields_preserves_others() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
let updated = r
.update(
&created.id,
UpdateProviderParams {
name: Some("New Name"),
enabled: Some(false),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "New Name");
assert!(!updated.enabled);
assert_eq!(updated.platform, "anthropic");
assert_eq!(updated.base_url, "https://api.anthropic.com");
}
#[tokio::test]
async fn update_api_key_changes_encrypted_value() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
let updated = r
.update(
&created.id,
UpdateProviderParams {
api_key_encrypted: Some("new_encrypted"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.api_key_encrypted, "new_encrypted");
}
#[tokio::test]
async fn update_optional_fields_can_be_set_and_cleared() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
assert!(created.bedrock_config.is_none());
// Set
let with_config = r
.update(
&created.id,
UpdateProviderParams {
bedrock_config: Some(Some(r#"{"region":"eu-west-1"}"#)),
..Default::default()
},
)
.await
.unwrap();
assert!(with_config.bedrock_config.is_some());
// Clear
let cleared = r
.update(
&created.id,
UpdateProviderParams {
bedrock_config: Some(None),
..Default::default()
},
)
.await
.unwrap();
assert!(cleared.bedrock_config.is_none());
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let r = repo().await;
let err = r
.update("nonexistent", UpdateProviderParams::default())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)), "expected NotFound, got: {err:?}");
}
#[tokio::test]
async fn update_advances_updated_at() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
let updated = r
.update(
&created.id,
UpdateProviderParams {
name: Some("Changed"),
..Default::default()
},
)
.await
.unwrap();
assert!(updated.updated_at >= created.updated_at);
assert_eq!(updated.created_at, created.created_at);
}
// -- Delete --
#[tokio::test]
async fn delete_removes_provider() {
let r = repo().await;
let created = r.create(sample_params()).await.unwrap();
r.delete(&created.id).await.unwrap();
assert!(r.find_by_id(&created.id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let r = repo().await;
let err = r.delete("nonexistent").await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)), "expected NotFound, got: {err:?}");
}
#[tokio::test]
async fn delete_does_not_affect_other_providers() {
let r = repo().await;
let p1 = r.create(sample_params()).await.unwrap();
let p2 = r
.create(CreateProviderParams {
name: "Other",
..sample_params()
})
.await
.unwrap();
r.delete(&p1.id).await.unwrap();
let all = r.list().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, p2.id);
}
@@ -0,0 +1,340 @@
//! Black-box integration tests for `IRemoteAgentRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
use std::sync::Arc;
use nomifun_db::{
CreateRemoteAgentParams, DbError, IRemoteAgentRepository, SqliteRemoteAgentRepository, UpdateRemoteAgentParams,
init_database_memory,
};
async fn repo() -> (Arc<dyn IRemoteAgentRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let r = Arc::new(SqliteRemoteAgentRepository::new(db.pool().clone()));
(r as Arc<dyn IRemoteAgentRepository>, db)
}
fn bearer_params() -> CreateRemoteAgentParams<'static> {
CreateRemoteAgentParams {
name: "Remote Server",
protocol: "acp",
url: "wss://remote.example.com",
auth_type: "bearer",
auth_token: Some("encrypted_bearer_token"),
allow_insecure: false,
avatar: None,
description: Some("Production agent"),
device_id: None,
device_public_key: None,
device_private_key: None,
device_token: None,
}
}
fn openclaw_params() -> CreateRemoteAgentParams<'static> {
CreateRemoteAgentParams {
name: "OpenClaw Agent",
protocol: "openClaw",
url: "wss://openclaw.example.com",
auth_type: "none",
auth_token: None,
allow_insecure: false,
avatar: Some("https://example.com/avatar.png"),
description: None,
device_id: Some("dev-abc-123"),
device_public_key: Some("enc_ed25519_pub"),
device_private_key: Some("enc_ed25519_priv"),
device_token: Some("enc_device_tok"),
}
}
// -- 1.1 Create Remote Agent --
#[tokio::test]
async fn create_bearer_agent_returns_complete_object() {
let (r, _db) = repo().await;
let agent = r.create(bearer_params()).await.unwrap();
assert!(agent.id > 0);
assert_eq!(agent.name, "Remote Server");
assert_eq!(agent.protocol, "acp");
assert_eq!(agent.url, "wss://remote.example.com");
assert_eq!(agent.auth_type, "bearer");
assert_eq!(agent.auth_token.as_deref(), Some("encrypted_bearer_token"));
assert!(!agent.allow_insecure);
assert_eq!(agent.status, "unknown");
assert!(agent.last_connected_at.is_none());
assert!(agent.created_at > 0);
assert!(agent.updated_at > 0);
}
#[tokio::test]
async fn create_openclaw_agent_includes_device_fields() {
let (r, _db) = repo().await;
let agent = r.create(openclaw_params()).await.unwrap();
assert_eq!(agent.protocol, "openClaw");
assert_eq!(agent.device_id.as_deref(), Some("dev-abc-123"));
assert_eq!(agent.device_public_key.as_deref(), Some("enc_ed25519_pub"));
assert_eq!(agent.device_private_key.as_deref(), Some("enc_ed25519_priv"));
assert_eq!(agent.device_token.as_deref(), Some("enc_device_tok"));
}
// -- 1.2 List Remote Agents --
#[tokio::test]
async fn list_empty_returns_empty_vec() {
let (r, _db) = repo().await;
let agents = r.list().await.unwrap();
assert!(agents.is_empty());
}
#[tokio::test]
async fn list_returns_all_agents_ordered() {
let (r, _db) = repo().await;
let a1 = r.create(bearer_params()).await.unwrap();
let a2 = r.create(openclaw_params()).await.unwrap();
let all = r.list().await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, a1.id);
assert_eq!(all[1].id, a2.id);
}
// -- 1.3 Get Single Remote Agent --
#[tokio::test]
async fn find_by_id_returns_full_record() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
assert_eq!(found.name, "Remote Server");
assert_eq!(found.auth_token.as_deref(), Some("encrypted_bearer_token"));
assert_eq!(found.description.as_deref(), Some("Production agent"));
}
#[tokio::test]
async fn find_by_id_nonexistent_returns_none() {
let (r, _db) = repo().await;
let result = r.find_by_id(999_999).await.unwrap();
assert!(result.is_none());
}
// -- 1.4 Update Remote Agent --
#[tokio::test]
async fn update_name_only_preserves_other_fields() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
let updated = r
.update(
created.id,
UpdateRemoteAgentParams {
name: Some("New Name"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "New Name");
assert_eq!(updated.protocol, created.protocol);
assert_eq!(updated.url, created.url);
assert_eq!(updated.auth_type, created.auth_type);
assert_eq!(updated.auth_token, created.auth_token);
assert_eq!(updated.allow_insecure, created.allow_insecure);
}
#[tokio::test]
async fn update_multiple_fields() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
let updated = r
.update(
created.id,
UpdateRemoteAgentParams {
name: Some("Updated"),
url: Some("wss://new-url.example.com"),
auth_token: Some(Some("new_encrypted_token")),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "Updated");
assert_eq!(updated.url, "wss://new-url.example.com");
assert_eq!(updated.auth_token.as_deref(), Some("new_encrypted_token"));
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r
.update(999_999, UpdateRemoteAgentParams::default())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn update_can_clear_optional_fields() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
assert!(created.description.is_some());
assert!(created.auth_token.is_some());
let updated = r
.update(
created.id,
UpdateRemoteAgentParams {
description: Some(None),
auth_token: Some(None),
..Default::default()
},
)
.await
.unwrap();
assert!(updated.description.is_none());
assert!(updated.auth_token.is_none());
}
#[tokio::test]
async fn update_persists_to_database() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
r.update(
created.id,
UpdateRemoteAgentParams {
name: Some("Persisted Name"),
..Default::default()
},
)
.await
.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.name, "Persisted Name");
}
// -- 1.5 Delete Remote Agent --
#[tokio::test]
async fn delete_existing_removes_record() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
r.delete(created.id).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r.delete(999_999).await.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
#[tokio::test]
async fn delete_one_does_not_affect_others() {
let (r, _db) = repo().await;
let a1 = r.create(bearer_params()).await.unwrap();
let a2 = r.create(openclaw_params()).await.unwrap();
r.delete(a1.id).await.unwrap();
let remaining = r.list().await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].id, a2.id);
}
// -- Status updates --
#[tokio::test]
async fn update_status_to_connected_with_timestamp() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
let ts = nomifun_common::now_ms();
r.update_status(created.id, "connected", Some(ts)).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.status, "connected");
assert_eq!(found.last_connected_at, Some(ts));
}
#[tokio::test]
async fn update_status_to_error_without_timestamp() {
let (r, _db) = repo().await;
let created = r.create(bearer_params()).await.unwrap();
r.update_status(created.id, "error", None).await.unwrap();
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.status, "error");
assert!(found.last_connected_at.is_none());
}
#[tokio::test]
async fn update_status_nonexistent_returns_not_found() {
let (r, _db) = repo().await;
let err = r
.update_status(999_999, "connected", None)
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound(_)));
}
// -- Full CRUD lifecycle --
#[tokio::test]
async fn full_crud_lifecycle() {
let (r, _db) = repo().await;
// Create
let created = r.create(bearer_params()).await.unwrap();
assert_eq!(created.name, "Remote Server");
// Read
let found = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(found.id, created.id);
// Update
let updated = r
.update(
created.id,
UpdateRemoteAgentParams {
name: Some("Renamed Server"),
description: Some(Some("Updated desc")),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "Renamed Server");
assert_eq!(updated.description.as_deref(), Some("Updated desc"));
// Update status
r.update_status(created.id, "connected", Some(nomifun_common::now_ms()))
.await
.unwrap();
let after_status = r.find_by_id(created.id).await.unwrap().unwrap();
assert_eq!(after_status.status, "connected");
// Delete
r.delete(created.id).await.unwrap();
assert!(r.find_by_id(created.id).await.unwrap().is_none());
// List should be empty
assert!(r.list().await.unwrap().is_empty());
}
@@ -0,0 +1,77 @@
//! Black-box integration tests for ISettingsRepository.
//!
//! Tests exercise the public trait interface against an in-memory SQLite database.
use std::sync::Arc;
use nomifun_db::{ISettingsRepository, SqliteSettingsRepository, init_database_memory};
async fn repo() -> Arc<dyn ISettingsRepository> {
let db = init_database_memory().await.unwrap();
Arc::new(SqliteSettingsRepository::new(db.pool().clone()))
}
// -- Get default state --
#[tokio::test]
async fn get_settings_returns_none_when_no_row_exists() {
let r = repo().await;
assert!(r.get_settings().await.unwrap().is_none());
}
// -- Upsert creates a row --
#[tokio::test]
async fn upsert_creates_settings_with_given_values() {
let r = repo().await;
let s = r.upsert_settings("zh-CN", false, true, true, false).await.unwrap();
assert_eq!(s.language, "zh-CN");
assert!(!s.notification_enabled);
assert!(s.cron_notification_enabled);
assert!(s.command_queue_enabled);
assert!(!s.save_upload_to_workspace);
assert!(s.updated_at > 0);
}
// -- Upsert then get round-trip --
#[tokio::test]
async fn upsert_then_get_returns_consistent_data() {
let r = repo().await;
r.upsert_settings("en-US", true, false, false, true).await.unwrap();
let s = r.get_settings().await.unwrap().unwrap();
assert_eq!(s.language, "en-US");
assert!(s.notification_enabled);
assert!(!s.cron_notification_enabled);
assert!(!s.command_queue_enabled);
assert!(s.save_upload_to_workspace);
}
// -- Upsert overwrites --
#[tokio::test]
async fn upsert_overwrites_previous_settings() {
let r = repo().await;
r.upsert_settings("en-US", true, false, false, false).await.unwrap();
r.upsert_settings("zh-CN", false, true, true, true).await.unwrap();
let s = r.get_settings().await.unwrap().unwrap();
assert_eq!(s.language, "zh-CN");
assert!(!s.notification_enabled);
assert!(s.cron_notification_enabled);
assert!(s.command_queue_enabled);
assert!(s.save_upload_to_workspace);
}
// -- updated_at advances on each upsert --
#[tokio::test]
async fn upsert_advances_updated_at() {
let r = repo().await;
let first = r.upsert_settings("en-US", true, false, false, false).await.unwrap();
let second = r.upsert_settings("en-US", true, false, false, false).await.unwrap();
assert!(second.updated_at >= first.updated_at);
}
@@ -0,0 +1,609 @@
//! Black-box integration tests for `ITeamRepository`.
//!
//! Tests exercise the repository trait interface without knowledge of
//! the underlying SQLite implementation details.
//!
//! Reworked for the primary-key redesign (spec §5.4/§5.5): the `teams.agents`
//! JSON array is now the `team_agents` table; `team_tasks.blocked_by`/`blocks`
//! JSON arrays are now the `team_task_deps` edge table; `mailbox.id` is an i64
//! autoincrement key. `delete_team` relies on FK CASCADE (no
//! `delete_mailbox_by_team` / `delete_tasks_by_team` helpers).
use std::sync::Arc;
use nomifun_common::now_ms;
use nomifun_db::models::{MailboxMessageRow, TeamAgentRow, TeamRow, TeamTaskRow};
use nomifun_db::{
DbError, ITeamRepository, SqliteTeamRepository, UpdateTaskParams, UpdateTeamParams, init_database_memory,
};
/// Builds a repo over an in-memory DB seeded with a conversation so the
/// `team_agents.conversation_id` FK (CASCADE) holds.
///
/// `system_default_user` (and the 20 built-in agents) are already seeded by
/// `init_database_memory` via `ensure_system_user`, so we only add the slot
/// conversation that `make_agent` references.
async fn repo() -> (Arc<dyn ITeamRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let r = Arc::new(SqliteTeamRepository::new(db.pool().clone()));
sqlx::query(
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
VALUES (1, 'system_default_user', 'Slot Conv', 'normal', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
(r as Arc<dyn ITeamRepository>, db)
}
fn make_team(id: &str, name: &str) -> TeamRow {
let now = now_ms();
TeamRow {
id: id.into(),
user_id: "system_default_user".into(),
name: name.into(),
workspace: String::new(),
workspace_mode: "shared".into(),
lead_agent_id: Some("a1".into()),
session_mode: None,
agents_version: "1.0.1".into(),
created_at: now,
updated_at: now,
}
}
fn make_agent(slot_id: &str, team_id: &str, name: &str, sort_order: i64) -> TeamAgentRow {
TeamAgentRow {
slot_id: slot_id.into(),
team_id: team_id.into(),
name: name.into(),
role: "teammate".into(),
conversation_id: Some(1),
backend: "claude".into(),
model: String::new(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
sort_order,
}
}
fn make_mailbox_msg(team_id: &str, to: &str, from: &str, msg_type: &str) -> MailboxMessageRow {
MailboxMessageRow {
id: 0, // ignored on insert (INTEGER PRIMARY KEY AUTOINCREMENT)
team_id: team_id.into(),
to_agent_id: to.into(),
from_agent_id: from.into(),
msg_type: msg_type.into(),
content: "content".into(),
summary: None,
files: None,
read: false,
created_at: now_ms(),
}
}
fn make_task(id: &str, team_id: &str, subject: &str) -> TeamTaskRow {
let now = now_ms();
TeamTaskRow {
id: id.into(),
team_id: team_id.into(),
subject: subject.into(),
description: None,
status: "pending".into(),
owner: None,
metadata: None,
created_at: now,
updated_at: now,
}
}
// ── Team CRUD Tests ──────────────────────────────────────────────────
#[tokio::test]
async fn create_and_get_team() {
let (repo, _db) = repo().await;
let team = make_team("t1", "Team Alpha");
repo.create_team(&team).await.unwrap();
let fetched = repo.get_team("t1").await.unwrap().expect("team exists");
assert_eq!(fetched.id, "t1");
assert_eq!(fetched.name, "Team Alpha");
assert_eq!(fetched.lead_agent_id, Some("a1".into()));
}
#[tokio::test]
async fn get_nonexistent_team_returns_none() {
let (repo, _db) = repo().await;
let result = repo.get_team("nonexistent").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn list_teams_empty() {
let (repo, _db) = repo().await;
let teams = repo.list_teams().await.unwrap();
assert!(teams.is_empty());
}
#[tokio::test]
async fn list_teams_multiple() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Alpha")).await.unwrap();
repo.create_team(&make_team("t2", "Beta")).await.unwrap();
let teams = repo.list_teams().await.unwrap();
assert_eq!(teams.len(), 2);
assert_eq!(teams[0].id, "t1");
assert_eq!(teams[1].id, "t2");
}
#[tokio::test]
async fn update_team_name() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Old Name")).await.unwrap();
repo.update_team(
"t1",
&UpdateTeamParams {
name: Some("New Name".into()),
..Default::default()
},
)
.await
.unwrap();
let team = repo.get_team("t1").await.unwrap().unwrap();
assert_eq!(team.name, "New Name");
}
#[tokio::test]
async fn update_team_lead_agent() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.update_team(
"t1",
&UpdateTeamParams {
lead_agent_id: Some("slot_new".into()),
..Default::default()
},
)
.await
.unwrap();
let team = repo.get_team("t1").await.unwrap().unwrap();
assert_eq!(team.lead_agent_id.as_deref(), Some("slot_new"));
}
#[tokio::test]
async fn update_nonexistent_team_returns_not_found() {
let (repo, _db) = repo().await;
let result = repo
.update_team(
"nonexistent",
&UpdateTeamParams {
name: Some("X".into()),
..Default::default()
},
)
.await;
assert!(matches!(result, Err(DbError::NotFound(_))));
}
#[tokio::test]
async fn delete_team() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.delete_team("t1").await.unwrap();
let result = repo.get_team("t1").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_nonexistent_team_returns_not_found() {
let (repo, _db) = repo().await;
let result = repo.delete_team("nonexistent").await;
assert!(matches!(result, Err(DbError::NotFound(_))));
}
// ── Team Agents Tests (was teams.agents JSON array) ──────────────────
#[tokio::test]
async fn create_list_and_order_team_agents() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_team_agent(&make_agent("a2", "t1", "Builder", 1)).await.unwrap();
repo.create_team_agent(&make_agent("a1", "t1", "Lead", 0)).await.unwrap();
let agents = repo.list_team_agents("t1").await.unwrap();
assert_eq!(agents.len(), 2);
// Ordered by sort_order ascending: a1 (0) before a2 (1).
assert_eq!(agents[0].slot_id, "a1");
assert_eq!(agents[1].slot_id, "a2");
assert_eq!(agents[0].conversation_id, Some(1));
}
#[tokio::test]
async fn get_rename_and_remove_team_agent() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_team_agent(&make_agent("a1", "t1", "Lead", 0)).await.unwrap();
let one = repo.get_team_agent("a1").await.unwrap().expect("agent exists");
assert_eq!(one.name, "Lead");
repo.rename_team_agent("a1", "Architect").await.unwrap();
let renamed = repo.get_team_agent("a1").await.unwrap().unwrap();
assert_eq!(renamed.name, "Architect");
repo.remove_team_agent("a1").await.unwrap();
assert!(repo.get_team_agent("a1").await.unwrap().is_none());
assert!(repo.list_team_agents("t1").await.unwrap().is_empty());
}
#[tokio::test]
async fn rename_nonexistent_agent_returns_not_found() {
let (repo, _db) = repo().await;
let result = repo.rename_team_agent("nope", "X").await;
assert!(matches!(result, Err(DbError::NotFound(_))));
}
// ── Mailbox Tests ────────────────────────────────────────────────────
#[tokio::test]
async fn write_message_returns_autoincrement_i64_id() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let id1 = repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
let id2 = repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
assert!(id1 > 0);
assert!(id2 > id1);
}
#[tokio::test]
async fn write_and_read_unread_messages() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
for _ in 1..=3 {
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
}
let unread = repo.read_unread_and_mark("t1", "a1").await.unwrap();
assert_eq!(unread.len(), 3);
assert!(!unread[0].read); // returned rows reflect pre-mark state
assert_eq!(unread[0].msg_type, "message");
let unread2 = repo.read_unread_and_mark("t1", "a1").await.unwrap();
assert!(unread2.is_empty());
}
#[tokio::test]
async fn peek_and_mark_read_batch_by_id() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let id1 = repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
let id2 = repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
// mark_read_batch now takes &[i64].
repo.mark_read_batch(&[id1]).await.unwrap();
let unread = repo.peek_unread("t1", "a1").await.unwrap();
assert_eq!(unread.len(), 1);
assert_eq!(unread[0].id, id2);
}
#[tokio::test]
async fn read_unread_no_messages() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let unread = repo.read_unread_and_mark("t1", "a1").await.unwrap();
assert!(unread.is_empty());
}
#[tokio::test]
async fn write_idle_notification_with_summary() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let mut msg = make_mailbox_msg("t1", "a1", "a2", "idle_notification");
msg.summary = Some("Task completed".into());
repo.write_message(&msg).await.unwrap();
let history = repo.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].msg_type, "idle_notification");
assert_eq!(history[0].summary.as_deref(), Some("Task completed"));
}
#[tokio::test]
async fn write_shutdown_request() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "shutdown_request")).await.unwrap();
let history = repo.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].msg_type, "shutdown_request");
}
#[tokio::test]
async fn get_history_with_limit() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
for _ in 1..=10 {
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
}
let history = repo.get_history("t1", "a1", Some(5)).await.unwrap();
assert_eq!(history.len(), 5);
}
#[tokio::test]
async fn get_history_no_limit() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
for _ in 1..=3 {
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
}
let history = repo.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 3);
}
#[tokio::test]
async fn get_history_empty() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let history = repo.get_history("t1", "a1", None).await.unwrap();
assert!(history.is_empty());
}
#[tokio::test]
async fn get_history_includes_read_messages() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
repo.read_unread_and_mark("t1", "a1").await.unwrap();
let history = repo.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 1);
assert!(history[0].read);
}
// ── Task Board Tests ─────────────────────────────────────────────────
#[tokio::test]
async fn create_and_list_tasks() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tk1", "t1", "Implement feature")).await.unwrap();
let tasks = repo.list_tasks("t1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Implement feature");
assert_eq!(tasks[0].status, "pending");
}
#[tokio::test]
async fn list_tasks_empty() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let tasks = repo.list_tasks("t1").await.unwrap();
assert!(tasks.is_empty());
}
#[tokio::test]
async fn find_task_by_id() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tk1", "t1", "Task")).await.unwrap();
let found = repo.find_task_by_id("t1", "tk1").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, "tk1");
}
#[tokio::test]
async fn find_task_by_id_not_found() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
let found = repo.find_task_by_id("t1", "nonexistent").await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn update_task_status() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tk1", "t1", "Task")).await.unwrap();
repo.update_task(
"tk1",
&UpdateTaskParams {
status: Some("in_progress".into()),
..Default::default()
},
)
.await
.unwrap();
let updated = repo.find_task_by_id("t1", "tk1").await.unwrap().unwrap();
assert_eq!(updated.status, "in_progress");
}
#[tokio::test]
async fn update_task_description_and_owner() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tk1", "t1", "Task")).await.unwrap();
repo.update_task(
"tk1",
&UpdateTaskParams {
description: Some("New description".into()),
owner: Some("agent-2".into()),
..Default::default()
},
)
.await
.unwrap();
let updated = repo.find_task_by_id("t1", "tk1").await.unwrap().unwrap();
assert_eq!(updated.description.as_deref(), Some("New description"));
assert_eq!(updated.owner.as_deref(), Some("agent-2"));
}
#[tokio::test]
async fn update_nonexistent_task_returns_not_found() {
let (repo, _db) = repo().await;
let result = repo
.update_task(
"nonexistent",
&UpdateTaskParams {
status: Some("completed".into()),
..Default::default()
},
)
.await;
assert!(matches!(result, Err(DbError::NotFound(_))));
}
// ── Task Dependency Tests (was blocked_by/blocks JSON arrays) ────────
#[tokio::test]
async fn add_and_remove_task_dep() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
// Both task rows must exist for the team_task_deps FK.
repo.create_task(&make_task("tkA", "t1", "Task A")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "Task B")).await.unwrap();
// tkA blocks tkB.
repo.add_task_dep("tkA", "tkB").await.unwrap();
// "what tkA blocks" and "who blocks tkB".
assert_eq!(repo.list_blocking("tkA").await.unwrap(), vec!["tkB".to_string()]);
assert_eq!(repo.list_blockers("tkB").await.unwrap(), vec!["tkA".to_string()]);
// Completing tkA removes the edge.
repo.remove_task_dep("tkA", "tkB").await.unwrap();
assert!(repo.list_blockers("tkB").await.unwrap().is_empty());
}
#[tokio::test]
async fn add_task_dep_idempotent() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tkA", "t1", "A")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "B")).await.unwrap();
repo.add_task_dep("tkA", "tkB").await.unwrap();
repo.add_task_dep("tkA", "tkB").await.unwrap();
// INSERT OR IGNORE on the composite PK: no duplicate edge.
assert_eq!(repo.list_blocking("tkA").await.unwrap(), vec!["tkB".to_string()]);
}
#[tokio::test]
async fn multi_dependency_unblock() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tkA", "t1", "A")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "B")).await.unwrap();
repo.create_task(&make_task("tkC", "t1", "C")).await.unwrap();
// tkA blocks both tkB and tkC.
repo.add_task_dep("tkA", "tkB").await.unwrap();
repo.add_task_dep("tkA", "tkC").await.unwrap();
// Completing A unblocks both.
repo.remove_task_dep("tkA", "tkB").await.unwrap();
repo.remove_task_dep("tkA", "tkC").await.unwrap();
assert!(repo.list_blockers("tkB").await.unwrap().is_empty());
assert!(repo.list_blockers("tkC").await.unwrap().is_empty());
}
#[tokio::test]
async fn partial_unblock_preserves_other_blockers() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tkA", "t1", "A")).await.unwrap();
repo.create_task(&make_task("tkX", "t1", "X")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "B")).await.unwrap();
// tkB is blocked by both tkA and tkX.
repo.add_task_dep("tkA", "tkB").await.unwrap();
repo.add_task_dep("tkX", "tkB").await.unwrap();
// Complete A only.
repo.remove_task_dep("tkA", "tkB").await.unwrap();
assert_eq!(repo.list_blockers("tkB").await.unwrap(), vec!["tkX".to_string()]);
}
// ── Data Consistency Tests ───────────────────────────────────────────
#[tokio::test]
async fn delete_team_cascades_agents_mailbox_tasks_and_deps() {
let (repo, db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_team_agent(&make_agent("a1", "t1", "Lead", 0)).await.unwrap();
repo.write_message(&make_mailbox_msg("t1", "a1", "a2", "message")).await.unwrap();
repo.create_task(&make_task("tkA", "t1", "A")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "B")).await.unwrap();
repo.add_task_dep("tkA", "tkB").await.unwrap();
// Single delete — FK CASCADE handles the rest (no manual cleanup helpers).
repo.delete_team("t1").await.unwrap();
assert!(repo.get_team("t1").await.unwrap().is_none());
assert!(repo.list_team_agents("t1").await.unwrap().is_empty());
assert!(repo.get_history("t1", "a1", None).await.unwrap().is_empty());
assert!(repo.list_tasks("t1").await.unwrap().is_empty());
let deps: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM team_task_deps")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(deps.0, 0);
}
#[tokio::test]
async fn task_dependency_directionality_is_consistent() {
let (repo, _db) = repo().await;
repo.create_team(&make_team("t1", "Team")).await.unwrap();
repo.create_task(&make_task("tkA", "t1", "A")).await.unwrap();
repo.create_task(&make_task("tkB", "t1", "B")).await.unwrap();
repo.add_task_dep("tkA", "tkB").await.unwrap();
// A single directed edge yields both views consistently.
assert!(
repo.list_blocking("tkA").await.unwrap().contains(&"tkB".to_string()),
"tkA should block tkB"
);
assert!(
repo.list_blockers("tkB").await.unwrap().contains(&"tkA".to_string()),
"tkB should be blocked by tkA"
);
}
@@ -0,0 +1,233 @@
//! Black-box integration tests for IUserRepository (test-plan T2.1 T2.13).
//!
//! Tests exercise the public trait interface against an in-memory SQLite database.
//! Internal details like SQL queries or column names are not referenced.
use std::sync::Arc;
use nomifun_db::{DbError, IUserRepository, SqliteUserRepository, init_database_memory};
async fn repo() -> Arc<dyn IUserRepository> {
let db = init_database_memory().await.unwrap();
Arc::new(SqliteUserRepository::new(db.pool().clone()))
}
// -- T2.1 Create user --
#[tokio::test]
async fn t2_1_create_user_returns_user_with_populated_fields() {
let r = repo().await;
let user = r.create_user("testuser", "$2b$12$fakehash").await.unwrap();
assert!(!user.id.is_empty(), "id should be non-empty");
assert_eq!(user.username, "testuser");
assert_eq!(user.password_hash, "$2b$12$fakehash");
assert!(user.created_at > 0);
assert!(user.updated_at > 0);
}
#[tokio::test]
async fn t2_1_create_user_duplicate_username_returns_conflict() {
let r = repo().await;
r.create_user("dup", "h1").await.unwrap();
let err = r.create_user("dup", "h2").await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)), "expected Conflict, got: {err:?}");
}
// -- T2.2 Find by username --
#[tokio::test]
async fn t2_2_find_by_username_existing() {
let r = repo().await;
r.create_user("findme", "h").await.unwrap();
let found = r.find_by_username("findme").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().username, "findme");
}
#[tokio::test]
async fn t2_2_find_by_username_nonexistent_returns_none() {
let r = repo().await;
assert!(r.find_by_username("ghost").await.unwrap().is_none());
}
// -- T2.3 Find by ID --
#[tokio::test]
async fn t2_3_find_by_id_existing() {
let r = repo().await;
let user = r.create_user("byid", "h").await.unwrap();
let found = r.find_by_id(&user.id).await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, user.id);
}
#[tokio::test]
async fn t2_3_find_by_id_nonexistent_returns_none() {
let r = repo().await;
assert!(r.find_by_id("no_such_id").await.unwrap().is_none());
}
// -- T2.4 List all users --
#[tokio::test]
async fn t2_4_list_users_returns_all() {
let r = repo().await;
r.create_user("u1", "h").await.unwrap();
r.create_user("u2", "h").await.unwrap();
let users = r.list_users().await.unwrap();
// system_default_user + u1 + u2
assert_eq!(users.len(), 3);
}
// -- T2.5 Count users --
#[tokio::test]
async fn t2_5_count_users() {
let r = repo().await;
r.create_user("counted", "h").await.unwrap();
// system_default_user + counted
assert_eq!(r.count_users().await.unwrap(), 2);
}
// -- T2.6 has_users --
#[tokio::test]
async fn t2_6_has_users_false_with_only_empty_password_system_user() {
let r = repo().await;
assert!(!r.has_users().await.unwrap());
}
#[tokio::test]
async fn t2_6_has_users_true_with_real_user() {
let r = repo().await;
r.create_user("real", "bcrypt_hash").await.unwrap();
assert!(r.has_users().await.unwrap());
}
// -- T2.7 Get system user --
#[tokio::test]
async fn t2_7_get_system_user_returns_default() {
let r = repo().await;
let user = r.get_system_user().await.unwrap();
assert!(user.is_some());
let user = user.unwrap();
assert_eq!(user.id, "system_default_user");
}
// -- T2.8 Get primary WebUI user --
#[tokio::test]
async fn t2_8_primary_webui_user_is_system_user_when_only_system() {
let r = repo().await;
let user = r.get_primary_webui_user().await.unwrap().unwrap();
assert_eq!(user.id, "system_default_user");
}
#[tokio::test]
async fn t2_8_primary_webui_user_prefers_system_over_admin() {
let r = repo().await;
// Can't create another user called "admin" now that seed uses it.
// The priority check still holds: any non-system user must not shadow system.
r.create_user("other", "h").await.unwrap();
let user = r.get_primary_webui_user().await.unwrap().unwrap();
assert_eq!(
user.id, "system_default_user",
"system user should take priority over non-system users"
);
}
// -- T2.9 Set system user credentials --
#[tokio::test]
async fn t2_9_set_system_user_credentials_updates_username_and_hash() {
let r = repo().await;
r.set_system_user_credentials("newadmin", "secure_hash").await.unwrap();
let user = r.get_system_user().await.unwrap().unwrap();
assert_eq!(user.username, "newadmin");
assert_eq!(user.password_hash, "secure_hash");
}
#[tokio::test]
async fn t2_9_set_system_user_credentials_conflict_with_existing_username() {
let r = repo().await;
r.create_user("existing", "h").await.unwrap();
let err = r.set_system_user_credentials("existing", "hash").await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)), "expected Conflict, got: {err:?}");
}
// -- T2.10 Update password --
#[tokio::test]
async fn t2_10_update_password_changes_hash_and_updated_at() {
let r = repo().await;
let user = r.create_user("pwduser", "old").await.unwrap();
r.update_password(&user.id, "new_hash").await.unwrap();
let updated = r.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(updated.password_hash, "new_hash");
assert!(updated.updated_at >= user.updated_at);
}
// -- T2.11 Update username --
#[tokio::test]
async fn t2_11_update_username_succeeds() {
let r = repo().await;
let user = r.create_user("oldname", "h").await.unwrap();
r.update_username(&user.id, "newname").await.unwrap();
let updated = r.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(updated.username, "newname");
}
#[tokio::test]
async fn t2_11_update_username_conflict_with_existing() {
let r = repo().await;
r.create_user("taken", "h").await.unwrap();
let other = r.create_user("free", "h").await.unwrap();
let err = r.update_username(&other.id, "taken").await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)), "expected Conflict, got: {err:?}");
}
// -- T2.12 Update last login --
#[tokio::test]
async fn t2_12_update_last_login_sets_timestamp() {
let r = repo().await;
let user = r.create_user("loginuser", "h").await.unwrap();
assert!(user.last_login.is_none());
r.update_last_login(&user.id).await.unwrap();
let updated = r.find_by_id(&user.id).await.unwrap().unwrap();
assert!(updated.last_login.is_some());
assert!(updated.last_login.unwrap() > 0);
}
// -- T2.13 Update JWT secret --
#[tokio::test]
async fn t2_13_update_jwt_secret_sets_value() {
let r = repo().await;
let user = r.create_user("jwtuser", "h").await.unwrap();
assert!(user.jwt_secret.is_none());
r.update_jwt_secret(&user.id, "my_secret").await.unwrap();
let updated = r.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(updated.jwt_secret.as_deref(), Some("my_secret"));
}
@@ -0,0 +1,121 @@
//! Integration tests for the webhook + tag_settings repositories.
use nomifun_db::models::{TagSettingRow, WebhookRow};
use nomifun_db::{
ITagSettingRepository, IWebhookRepository, SqliteTagSettingRepository, SqliteWebhookRepository,
init_database_memory,
};
use std::sync::Arc;
fn sample_webhook() -> WebhookRow {
WebhookRow {
// id is ignored on insert (AUTOINCREMENT assigns it); any value works.
id: 0,
name: "Team bot".into(),
platform: "lark".into(),
url: "https://open.feishu.cn/open-apis/bot/v2/hook/abc".into(),
secret: Some("s3cr3t".into()),
description: "team notifications".into(),
enabled: true,
created_at: 1,
updated_at: 1,
}
}
#[tokio::test]
async fn webhook_crud_roundtrip() {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
// create
let id = repo.insert(&sample_webhook()).await.unwrap();
// get
let got = repo.get_by_id(id).await.unwrap().expect("present");
assert_eq!(got.name, "Team bot");
assert_eq!(got.secret.as_deref(), Some("s3cr3t"));
// list
repo.insert(&sample_webhook()).await.unwrap();
let all = repo.list_all().await.unwrap();
assert_eq!(all.len(), 2);
// update
let mut upd = got.clone();
upd.name = "Renamed".into();
upd.enabled = false;
upd.updated_at = 9;
repo.update(&upd).await.unwrap();
let after = repo.get_by_id(id).await.unwrap().unwrap();
assert_eq!(after.name, "Renamed");
assert!(!after.enabled);
// delete
repo.delete(id).await.unwrap();
assert!(repo.get_by_id(id).await.unwrap().is_none());
}
#[tokio::test]
async fn webhook_update_and_delete_missing_is_not_found() {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
let err = repo.delete(9999).await.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
let mut ghost = sample_webhook();
ghost.id = 9999;
let err = repo.update(&ghost).await.unwrap_err();
assert!(matches!(err, nomifun_db::DbError::NotFound(_)));
}
#[tokio::test]
async fn tag_setting_upsert_get_list_delete() {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn ITagSettingRepository> = Arc::new(SqliteTagSettingRepository::new(db.pool().clone()));
// Seed a webhook so the tag_settings.webhook_id FK is satisfiable.
let wh_repo: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
let wh_id = wh_repo.insert(&sample_webhook()).await.unwrap();
// absent → None
assert!(repo.get("alpha").await.unwrap().is_none());
// upsert (insert)
repo.upsert(&TagSettingRow {
tag: "alpha".into(),
webhook_id: Some(wh_id),
description: "queue alpha".into(),
notify_events: "done,failed,needs_review".to_string(),
updated_at: 5,
})
.await
.unwrap();
let got = repo.get("alpha").await.unwrap().unwrap();
assert_eq!(got.webhook_id, Some(wh_id));
// upsert (update — same key replaces)
repo.upsert(&TagSettingRow {
tag: "alpha".into(),
webhook_id: None,
description: "unbound now".into(),
notify_events: "done,failed,needs_review".to_string(),
updated_at: 6,
})
.await
.unwrap();
let got = repo.get("alpha").await.unwrap().unwrap();
assert_eq!(got.webhook_id, None);
assert_eq!(got.description, "unbound now");
// list
repo.upsert(&TagSettingRow {
tag: "beta".into(),
webhook_id: None,
description: String::new(),
notify_events: "done,failed,needs_review".to_string(),
updated_at: 7,
})
.await
.unwrap();
assert_eq!(repo.list_all().await.unwrap().len(), 2);
// delete (idempotent)
repo.delete("alpha").await.unwrap();
assert!(repo.get("alpha").await.unwrap().is_none());
repo.delete("alpha").await.unwrap(); // no error on absent
}