From 10abdfa0967c9dfb46bbf560e3eb659f86a8ddb6 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 27 Oct 2025 23:26:42 +0800 Subject: [PATCH] refactor(backend): phase 3 - expand integration tests for Codex and MCP sync Expand test suite from 3 to 11 integration tests, adding comprehensive coverage for Codex dual-file atomicity and bidirectional MCP synchronization: New Codex sync tests: - sync_codex_provider_writes_auth_and_config: validates atomic write of auth.json and config.toml, plus SSOT backfill of latest toml content - sync_enabled_to_codex_writes_enabled_servers: MCP projection to config.toml - sync_enabled_to_codex_removes_servers_when_none_enabled: cleanup when all disabled - sync_enabled_to_codex_returns_error_on_invalid_toml: error handling for malformed TOML New Codex MCP import tests: - import_from_codex_adds_servers_from_mcp_servers_table: imports new servers from live config - import_from_codex_merges_into_existing_entries: smart merge preserving SSOT server configs New Claude MCP tests: - sync_claude_enabled_mcp_projects_to_user_config: enabled/disabled filtering for .claude.json - import_from_claude_merges_into_config: intelligent merge preserving existing configurations Expand lib.rs API exports: - Codex paths: get_codex_auth_path, get_codex_config_path - Claude MCP: get_claude_mcp_path - MCP sync: sync_enabled_to_claude, sync_enabled_to_codex - MCP import: import_from_claude, import_from_codex - Error type: AppError (for test assertions) Test infrastructure improvements: - Enhanced reset_test_fs() to clean .claude.json - All tests use isolated HOME directory with sequential execution via mutex Test results: 11/11 passed Files changed: 3 (+394/-6 lines) Next steps: Command layer integration tests and error recovery scenarios --- docs/BACKEND_REFACTOR_PLAN.md | 6 +- src-tauri/src/lib.rs | 7 +- src-tauri/tests/import_export_sync.rs | 387 ++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 6 deletions(-) diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md index ac613a0..48bb582 100644 --- a/docs/BACKEND_REFACTOR_PLAN.md +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -77,9 +77,9 @@ - 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。 - 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。 - **阶段 3:补充测试 🚧** - - 新增 `tests/import_export_sync.rs` 集成测试,覆盖配置备份与 Claude 供应商 live 同步路径(使用隔离的 HOME 目录,避免污染真实环境)。 - - 扩展 `lib.rs` 对核心数据结构与错误处理 API 的导出,便于后续服务层测试复用。 - - 当前覆盖率聚焦配置导入导出模块,后续待补充 MCP 同步、供应商切换等跨模块场景。 + - `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。 + - 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。 + - 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。 ## 渐进式重构路线 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93d7e52..d1b48bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,10 +16,13 @@ mod store; mod usage_script; pub use app_config::{AppType, MultiAppConfig}; -pub use config::{get_claude_settings_path, read_json_file}; +pub use codex_config::{get_codex_auth_path, get_codex_config_path}; +pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use import_export::{create_backup, sync_current_providers_to_live}; pub use provider::Provider; pub use settings::{update_settings, AppSettings}; +pub use mcp::{import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex}; +pub use error::AppError; use store::AppState; use tauri::{ @@ -30,8 +33,6 @@ use tauri::{ use tauri::{ActivationPolicy, RunEvent}; use tauri::{Emitter, Manager}; -use crate::error::AppError; - /// 创建动态托盘菜单 fn create_tray_menu( app: &tauri::AppHandle, diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 4ca40ff..281fc32 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -34,6 +34,10 @@ fn reset_test_fs() { } } } + let claude_json = home.join(".claude.json"); + if claude_json.exists() { + let _ = fs::remove_file(&claude_json); + } // 重置内存中的设置缓存,确保测试环境不受上一次调用影响 // 写入默认设置即可刷新 OnceLock 中的缓存数据 let _ = update_settings(AppSettings::default()); @@ -102,6 +106,389 @@ fn sync_claude_provider_writes_live_settings() { ); } +#[test] +fn sync_codex_provider_writes_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + + // 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML + config + .mcp + .codex + .servers + .insert( + "echo-server".into(), + json!({ + "id": "echo-server", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["hello"] + } + }), + ); + + let provider_config = json!({ + "auth": { + "OPENAI_API_KEY": "codex-key" + }, + "config": r#"base_url = "https://codex.test""# + }); + + let provider = Provider::with_id( + "codex-1".to_string(), + "Codex Test".to_string(), + provider_config.clone(), + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert("codex-1".to_string(), provider); + manager.current = "codex-1".to_string(); + + sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let auth_path = cc_switch_lib::get_codex_auth_path(); + let config_path = cc_switch_lib::get_codex_config_path(); + + assert!( + auth_path.exists(), + "auth.json should exist at {}", + auth_path.display() + ); + assert!( + config_path.exists(), + "config.toml should exist at {}", + config_path.display() + ); + + let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth"); + assert_eq!( + auth_value, + provider_config.get("auth").cloned().expect("auth object") + ); + + let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); + assert!( + toml_text.contains("command = \"echo\""), + "config.toml should contain serialized enabled MCP server" + ); + + // 当前供应商应同步最新 config 文本 + let manager = config + .get_manager(&AppType::Codex) + .expect("codex manager"); + let synced = manager.providers.get("codex-1").expect("codex provider"); + let synced_cfg = synced + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + assert_eq!(synced_cfg, toml_text); +} + +#[test] +fn sync_enabled_to_codex_writes_enabled_servers() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let mut config = MultiAppConfig::default(); + config + .mcp + .codex + .servers + .insert( + "stdio-enabled".into(), + json!({ + "id": "stdio-enabled", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["ok"], + } + }), + ); + + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + + let path = cc_switch_lib::get_codex_config_path(); + assert!(path.exists(), "config.toml should be created"); + let text = fs::read_to_string(&path).expect("read config.toml"); + assert!( + text.contains("mcp_servers") && text.contains("stdio-enabled"), + "enabled servers should be serialized" + ); +} + +#[test] +fn sync_enabled_to_codex_removes_servers_when_none_enabled() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp_servers] +disabled = { type = "stdio", command = "noop" } +"#, + ) + .expect("seed config file"); + + let config = MultiAppConfig::default(); // 无启用项 + cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); + + let text = fs::read_to_string(&path).expect("read config.toml"); + assert!( + !text.contains("mcp_servers") && !text.contains("servers"), + "disabled entries should be removed from config.toml" + ); +} + +#[test] +fn sync_enabled_to_codex_returns_error_on_invalid_toml() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write(&path, "invalid = [").expect("write invalid config"); + + let mut config = MultiAppConfig::default(); + config.mcp.codex.servers.insert( + "broken".into(), + json!({ + "id": "broken", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo" + } + }), + ); + + let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err("sync should fail"); + match err { + cc_switch_lib::AppError::Toml { path, .. } => { + assert!(path.ends_with("config.toml"), "path should reference config.toml"); + } + cc_switch_lib::AppError::McpValidation(msg) => { + assert!(msg.contains("config.toml"), "error message should mention config.toml"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn import_from_codex_adds_servers_from_mcp_servers_table() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp_servers.echo_server] +type = "stdio" +command = "echo" +args = ["hello"] + +[mcp_servers.http_server] +type = "http" +url = "https://example.com" +"#, + ) + .expect("write codex config"); + + let mut config = MultiAppConfig::default(); + let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex"); + assert!(changed >= 2, "should import both servers"); + + let servers = &config.mcp.codex.servers; + let echo = servers.get("echo_server").and_then(|v| v.as_object()).expect("echo server"); + assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let server_spec = echo.get("server").and_then(|v| v.as_object()).expect("server spec"); + assert_eq!( + server_spec.get("command").and_then(|v| v.as_str()).unwrap_or(""), + "echo" + ); + + let http = servers.get("http_server").and_then(|v| v.as_object()).expect("http server"); + let http_spec = http.get("server").and_then(|v| v.as_object()).expect("http spec"); + assert_eq!( + http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""), + "https://example.com" + ); +} + +#[test] +fn import_from_codex_merges_into_existing_entries() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let path = cc_switch_lib::get_codex_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create codex dir"); + } + fs::write( + &path, + r#"[mcp.servers.existing] +type = "stdio" +command = "echo" +"#, + ) + .expect("write codex config"); + + let mut config = MultiAppConfig::default(); + config + .mcp + .codex + .servers + .insert( + "existing".into(), + json!({ + "id": "existing", + "name": "existing", + "enabled": false, + "server": { + "type": "stdio", + "command": "prev" + } + }), + ); + + let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex"); + assert!(changed >= 1, "should mark change for enabled flag"); + + let entry = config + .mcp + .codex + .servers + .get("existing") + .and_then(|v| v.as_object()) + .expect("existing entry"); + assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let spec = entry.get("server").and_then(|v| v.as_object()).expect("server spec"); + // 保留原 command,确保导入不会覆盖现有 server 细节 + assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev")); +} + +#[test] +fn sync_claude_enabled_mcp_projects_to_user_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let mut config = MultiAppConfig::default(); + + config.mcp.claude.servers.insert( + "stdio-enabled".into(), + json!({ + "id": "stdio-enabled", + "enabled": true, + "server": { + "type": "stdio", + "command": "echo", + "args": ["hi"], + } + }), + ); + config.mcp.claude.servers.insert( + "http-disabled".into(), + json!({ + "id": "http-disabled", + "enabled": false, + "server": { + "type": "http", + "url": "https://example.com", + } + }), + ); + + cc_switch_lib::sync_enabled_to_claude(&config).expect("sync Claude MCP"); + + let claude_path = cc_switch_lib::get_claude_mcp_path(); + assert!(claude_path.exists(), "claude config should exist"); + let text = fs::read_to_string(&claude_path).expect("read .claude.json"); + let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude json"); + let servers = value + .get("mcpServers") + .and_then(|v| v.as_object()) + .expect("mcpServers map"); + assert_eq!(servers.len(), 1, "only enabled entries should be written"); + let enabled = servers.get("stdio-enabled").expect("enabled entry"); + assert_eq!( + enabled + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "echo" + ); + assert!(servers.get("http-disabled").is_none()); +} + +#[test] +fn import_from_claude_merges_into_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + let claude_path = home.join(".claude.json"); + + fs::write( + &claude_path, + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "stdio-enabled": { + "type": "stdio", + "command": "echo", + "args": ["hello"] + } + } + })) + .unwrap(), + ) + .expect("write claude json"); + + let mut config = MultiAppConfig::default(); + config + .mcp + .claude + .servers + .insert("stdio-enabled".into(), json!({ + "id": "stdio-enabled", + "name": "stdio-enabled", + "enabled": false, + "server": { + "type": "stdio", + "command": "prev" + } + })); + + let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude"); + assert!(changed >= 1, "should mark at least one change"); + + let entry = config + .mcp + .claude + .servers + .get("stdio-enabled") + .and_then(|v| v.as_object()) + .expect("entry exists"); + assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true)); + let server = entry.get("server").and_then(|v| v.as_object()).expect("server obj"); + assert_eq!( + server.get("command").and_then(|v| v.as_str()).unwrap_or(""), + "prev", + "existing server config should be preserved" + ); +} + #[test] fn create_backup_skips_missing_file() { let _guard = test_mutex().lock().expect("acquire test mutex");