//! 数据库模块测试 //! //! 包含 Schema 迁移和基本功能的测试。 use super::*; use crate::app_config::MultiAppConfig; use crate::provider::{Provider, ProviderManager}; use indexmap::IndexMap; use rusqlite::Connection; use serde_json::json; use std::collections::HashMap; const LEGACY_SCHEMA_SQL: &str = r#" CREATE TABLE providers ( id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT NOT NULL, PRIMARY KEY (id, app_type) ); CREATE TABLE provider_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, url TEXT NOT NULL ); CREATE TABLE mcp_servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL ); CREATE TABLE prompts ( id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL, PRIMARY KEY (id, app_type) ); CREATE TABLE skills ( key TEXT PRIMARY KEY, installed BOOLEAN NOT NULL DEFAULT 0 ); CREATE TABLE skill_repos ( owner TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (owner, name) ); CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT ); "#; #[derive(Debug)] struct ColumnInfo { name: String, r#type: String, notnull: i64, default: Option, } fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo { let mut stmt = conn .prepare(&format!("PRAGMA table_info(\"{table}\");")) .expect("prepare pragma"); let mut rows = stmt.query([]).expect("query pragma"); while let Some(row) = rows.next().expect("read row") { let name: String = row.get(1).expect("name"); if name.eq_ignore_ascii_case(column) { return ColumnInfo { name, r#type: row.get::<_, String>(2).expect("type"), notnull: row.get::<_, i64>(3).expect("notnull"), default: row.get::<_, Option>(4).ok().flatten(), }; } } panic!("column {table}.{column} not found"); } fn normalize_default(default: &Option) -> Option { default .as_ref() .map(|s| s.trim_matches('\'').trim_matches('"').to_string()) } #[test] fn migration_sets_user_version_when_missing() { let conn = Connection::open_in_memory().expect("open memory db"); Database::create_tables_on_conn(&conn).expect("create tables"); assert_eq!( Database::get_user_version(&conn).expect("read version before"), 0 ); Database::apply_schema_migrations_on_conn(&conn).expect("apply migration"); assert_eq!( Database::get_user_version(&conn).expect("read version after"), SCHEMA_VERSION ); } #[test] fn migration_rejects_future_version() { let conn = Connection::open_in_memory().expect("open memory db"); Database::create_tables_on_conn(&conn).expect("create tables"); Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version"); let err = Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version"); assert!( err.to_string().contains("数据库版本过新"), "unexpected error: {err}" ); } #[test] fn migration_adds_missing_columns_for_providers() { let conn = Connection::open_in_memory().expect("open memory db"); // 创建旧版 providers 表,缺少新增列 conn.execute_batch(LEGACY_SCHEMA_SQL) .expect("seed old schema"); Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); // 验证关键新增列已补齐 for (table, column) in [ ("providers", "meta"), ("providers", "is_current"), ("provider_endpoints", "added_at"), ("mcp_servers", "enabled_gemini"), ("prompts", "updated_at"), ("skills", "installed_at"), ("skill_repos", "enabled"), ] { assert!( Database::has_column(&conn, table, column).expect("check column"), "{table}.{column} should exist after migration" ); } // 验证 meta 列约束保持一致 let meta = get_column_info(&conn, "providers", "meta"); assert_eq!(meta.notnull, 1, "meta should be NOT NULL"); assert_eq!( normalize_default(&meta.default).as_deref(), Some("{}"), "meta default should be '{{}}'" ); assert_eq!( Database::get_user_version(&conn).expect("version after migration"), SCHEMA_VERSION ); } #[test] fn migration_aligns_column_defaults_and_types() { let conn = Connection::open_in_memory().expect("open memory db"); conn.execute_batch(LEGACY_SCHEMA_SQL) .expect("seed old schema"); Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); let is_current = get_column_info(&conn, "providers", "is_current"); assert_eq!(is_current.r#type, "BOOLEAN"); assert_eq!(is_current.notnull, 1); assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0")); let tags = get_column_info(&conn, "mcp_servers", "tags"); assert_eq!(tags.r#type, "TEXT"); assert_eq!(tags.notnull, 1); assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]")); let enabled = get_column_info(&conn, "prompts", "enabled"); assert_eq!(enabled.r#type, "BOOLEAN"); assert_eq!(enabled.notnull, 1); assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1")); let installed_at = get_column_info(&conn, "skills", "installed_at"); assert_eq!(installed_at.r#type, "INTEGER"); assert_eq!(installed_at.notnull, 1); assert_eq!( normalize_default(&installed_at.default).as_deref(), Some("0") ); let branch = get_column_info(&conn, "skill_repos", "branch"); assert_eq!(branch.r#type, "TEXT"); assert_eq!(normalize_default(&branch.default).as_deref(), Some("main")); let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled"); assert_eq!(skill_repo_enabled.r#type, "BOOLEAN"); assert_eq!(skill_repo_enabled.notnull, 1); assert_eq!( normalize_default(&skill_repo_enabled.default).as_deref(), Some("1") ); } #[test] fn dry_run_does_not_write_to_disk() { // Create minimal valid config for migration let mut apps = HashMap::new(); apps.insert("claude".to_string(), ProviderManager::default()); let config = MultiAppConfig { version: 2, apps, mcp: Default::default(), prompts: Default::default(), skills: Default::default(), common_config_snippets: Default::default(), claude_common_config_snippet: None, }; // Dry-run should succeed without any file I/O errors let result = Database::migrate_from_json_dry_run(&config); assert!( result.is_ok(), "Dry-run should succeed with valid config: {result:?}" ); } #[test] fn dry_run_validates_schema_compatibility() { // Create config with actual provider data let mut providers = IndexMap::new(); providers.insert( "test-provider".to_string(), Provider { id: "test-provider".to_string(), name: "Test Provider".to_string(), settings_config: json!({ "anthropicApiKey": "sk-test-123", }), website_url: None, category: None, created_at: Some(1234567890), sort_index: None, notes: None, meta: None, icon: None, icon_color: None, is_proxy_target: Some(false), }, ); let mut manager = ProviderManager::default(); manager.providers = providers; manager.current = "test-provider".to_string(); let mut apps = HashMap::new(); apps.insert("claude".to_string(), manager); let config = MultiAppConfig { version: 2, apps, mcp: Default::default(), prompts: Default::default(), skills: Default::default(), common_config_snippets: Default::default(), claude_common_config_snippet: None, }; // Dry-run should validate the full migration path let result = Database::migrate_from_json_dry_run(&config); assert!( result.is_ok(), "Dry-run should succeed with provider data: {result:?}" ); } #[test] fn model_pricing_is_seeded_on_init() { let db = Database::memory().expect("create memory db"); let conn = db.conn.lock().expect("lock conn"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0)) .expect("count pricing"); assert!( count > 0, "模型定价数据应该在初始化时自动填充,实际数量: {}", count ); // 验证包含 Claude 模型 let claude_count: i64 = conn .query_row( "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'", [], |row| row.get(0), ) .expect("check claude"); assert!( claude_count > 0, "应该包含 Claude 模型定价,实际数量: {}", claude_count ); // 验证包含 GPT 模型 let gpt_count: i64 = conn .query_row( "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'", [], |row| row.get(0), ) .expect("check gpt"); assert!( gpt_count > 0, "应该包含 GPT 模型定价,实际数量: {}", gpt_count ); // 验证包含 Gemini 模型 let gemini_count: i64 = conn .query_row( "SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'", [], |row| row.get(0), ) .expect("check gemini"); assert!( gemini_count > 0, "应该包含 Gemini 模型定价,实际数量: {}", gemini_count ); }