Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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 001–021 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 2–21 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
|
||||
}
|
||||
Reference in New Issue
Block a user