refactor(backend): phase 3 - unify error handling and fix backup timestamp bug

Key improvements:
- Extract switch_provider_internal() returning AppError for better testability
- Fix backup mtime inheritance: use read+write instead of fs::copy to ensure latest backup survives cleanup
- Add 15+ integration tests covering provider commands, atomic writes, and rollback scenarios
- Expose write_codex_live_atomic, AppState, and test hooks in public API
- Extract tests/support.rs with isolated HOME and mutex utilities

Test coverage:
- Provider switching with live config backfill and MCP sync
- Codex atomic write success and failure rollback
- Backup retention policy with proper mtime ordering
- Negative cases: missing auth field, invalid provider ID
This commit is contained in:
Jason
2025-10-28 09:55:10 +08:00
parent 10abdfa096
commit 8e980e6974
7 changed files with 450 additions and 81 deletions

View File

@@ -79,6 +79,9 @@
- **阶段 3补充测试 🚧**
- `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 供应商缺少 `auth` 字段时的错误返回,并补充备份数量上限测试;顺带修复 `create_backup` 采用内存读写避免拷贝继承旧的修改时间,确保最新备份不会在清理阶段被误删。
- 针对 `codex_config::write_codex_live_atomic` 补充成功与失败场景测试,覆盖 auth/config 原子写入与失败回滚逻辑(模拟目标路径为目录时的 rename 失败),降低 Codex live 写入回归风险。
- 新增 `tests/provider_commands.rs` 覆盖 `switch_provider` 的 Codex 正常流程与供应商缺失分支,并抽取 `switch_provider_internal` 以复用 `AppError`,通过 `switch_provider_test_hook` 暴露测试入口;同时共享 `tests/support.rs` 提供隔离 HOME / 互斥工具函数。
- 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。
## 渐进式重构路线

View File

@@ -8,6 +8,7 @@ use tauri::State;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::store::AppState;
@@ -310,44 +311,32 @@ pub async fn delete_provider(
}
/// 切换供应商
#[tauri::command]
pub async fn switch_provider(
state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
id: String,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
use serde_json::Value;
let mut config = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
.map_err(|e| AppError::Message(format!("获取锁失败: {}", e)))?;
let provider = {
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone()
.get(id)
.cloned()
.ok_or_else(|| AppError::Message(format!("供应商不存在: {}", id)))?
};
match app_type {
AppType::Codex => {
use serde_json::Value;
if !{
let cur = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
cur.current.is_empty()
} {
let auth_path = codex_config::get_codex_auth_path();
@@ -356,7 +345,11 @@ pub async fn switch_provider(
let auth: Value = crate::config::read_json_file(&auth_path)?;
let config_str = if config_path.exists() {
std::fs::read_to_string(&config_path).map_err(|e| {
format!("读取 config.toml 失败: {}: {}", config_path.display(), e)
AppError::Message(format!(
"读取 config.toml 失败: {}: {}",
config_path.display(),
e
))
})?
} else {
String::new()
@@ -370,12 +363,12 @@ pub async fn switch_provider(
let cur_id2 = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
if let Some(cur) = m.providers.get_mut(&cur_id2) {
cur.settings_config = live;
}
@@ -385,7 +378,7 @@ pub async fn switch_provider(
let auth = provider
.settings_config
.get("auth")
.ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?;
.ok_or_else(|| AppError::Message("目标供应商缺少 auth 配置".to_string()))?;
let cfg_text = provider
.settings_config
.get("config")
@@ -401,14 +394,14 @@ pub async fn switch_provider(
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
m.current.clone()
};
if !cur_id.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
if let Some(cur) = m.providers.get_mut(&cur_id) {
cur.settings_config = live;
}
@@ -417,7 +410,8 @@ pub async fn switch_provider(
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?;
std::fs::create_dir_all(parent)
.map_err(|e| AppError::Message(format!("创建目录失败: {}", e)))?;
}
write_json_file(&settings_path, &provider.settings_config)?;
@@ -426,8 +420,8 @@ pub async fn switch_provider(
if let Ok(live_after) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(target) = m.providers.get_mut(&id) {
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
if let Some(target) = m.providers.get_mut(id) {
target.settings_config = live_after;
}
}
@@ -438,8 +432,8 @@ pub async fn switch_provider(
{
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current = id;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
manager.current = id.to_string();
}
if let AppType::Codex = app_type {
@@ -450,12 +444,12 @@ pub async fn switch_provider(
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
.ok_or_else(|| AppError::Message(format!("应用类型不存在: {:?}", app_type)))?;
if let Some(p) = m.providers.get_mut(&cur_id) {
if let Some(obj) = p.settings_config.as_object_mut() {
obj.insert(
@@ -471,7 +465,34 @@ pub async fn switch_provider(
drop(config);
state.save()?;
Ok(true)
Ok(())
}
#[doc(hidden)]
pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
switch_provider_internal(state, app_type, id)
}
#[tauri::command]
pub async fn switch_provider(
state: State<'_, AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
id: String,
) -> Result<bool, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
switch_provider_internal(&state, app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 导入当前配置为默认供应商

View File

@@ -28,8 +28,8 @@ pub fn create_backup(config_path: &PathBuf) -> Result<String, AppError> {
let backup_path = backup_dir.join(format!("{}.json", backup_id));
// 复制配置文件到备份
fs::copy(config_path, &backup_path).map_err(|e| AppError::io(&backup_path, e))?;
let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?;
fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?;
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;

View File

@@ -16,15 +16,16 @@ mod store;
mod usage_script;
pub use app_config::{AppType, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
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;
pub use store::AppState;
pub use commands::*;
use store::AppState;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},

View File

@@ -1,52 +1,14 @@
use std::fs;
use std::path::Path;
use serde_json::json;
use std::sync::{Mutex, OnceLock};
use cc_switch_lib::{
create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live,
update_settings, AppSettings, AppType, MultiAppConfig, Provider,
create_backup, get_claude_settings_path, read_json_file, sync_current_providers_to_live, AppType,
MultiAppConfig, Provider,
};
fn ensure_test_home() -> &'static Path {
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let base = std::env::temp_dir().join("cc-switch-test-home");
if base.exists() {
let _ = std::fs::remove_dir_all(&base);
}
std::fs::create_dir_all(&base).expect("create test home");
std::env::set_var("HOME", &base);
#[cfg(windows)]
std::env::set_var("USERPROFILE", &base);
base
})
.as_path()
}
fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch"] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = fs::remove_dir_all(&path) {
eprintln!("failed to clean {}: {}", path.display(), err);
}
}
}
let claude_json = home.join(".claude.json");
if claude_json.exists() {
let _ = fs::remove_file(&claude_json);
}
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
// 写入默认设置即可刷新 OnceLock 中的缓存数据
let _ = update_settings(AppSettings::default());
}
fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
MUTEX.get_or_init(|| Mutex::new(()))
}
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn sync_claude_provider_writes_live_settings() {
@@ -287,6 +249,129 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
}
}
#[test]
fn sync_codex_provider_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
let provider = Provider::with_id(
"codex-missing-auth".to_string(),
"No Auth".to_string(),
json!({
"config": "model = \"test\""
}),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(provider.id.clone(), provider);
manager.current = "codex-missing-auth".to_string();
let err = sync_current_providers_to_live(&mut config)
.expect_err("sync should fail when auth missing");
match err {
cc_switch_lib::AppError::Config(msg) => {
assert!(msg.contains("auth"), "error message should mention auth");
}
other => panic!("unexpected error variant: {other:?}"),
}
// 确认未产生任何 live 配置文件
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"auth.json should not be created on failure"
);
assert!(
!cc_switch_lib::get_codex_config_path().exists(),
"config.toml should not be created on failure"
);
}
#[test]
fn write_codex_live_atomic_persists_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let auth = json!({ "OPENAI_API_KEY": "dev-key" });
let config_text = r#"
[mcp_servers.echo]
type = "stdio"
command = "echo"
args = ["ok"]
"#;
cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
.expect("atomic write should succeed");
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 be created");
assert!(config_path.exists(), "config.toml should be created");
let stored_auth: serde_json::Value =
cc_switch_lib::read_json_file(&auth_path).expect("read auth");
assert_eq!(stored_auth, auth, "auth.json should match input");
let stored_config = std::fs::read_to_string(&config_path).expect("read config");
assert!(
stored_config.contains("mcp_servers.echo"),
"config.toml should contain serialized table"
);
}
#[test]
fn write_codex_live_atomic_rolls_back_auth_when_config_write_fails() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let auth_path = cc_switch_lib::get_codex_auth_path();
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent).expect("create codex dir");
}
std::fs::write(&auth_path, r#"{"OPENAI_API_KEY":"legacy"}"#).expect("seed auth");
let config_path = cc_switch_lib::get_codex_config_path();
std::fs::create_dir_all(&config_path).expect("create blocking directory");
let auth = json!({ "OPENAI_API_KEY": "new-key" });
let config_text = r#"[mcp_servers.sample]
type = "stdio"
command = "noop"
"#;
let err = cc_switch_lib::write_codex_live_atomic(&auth, Some(config_text))
.expect_err("config write should fail when target is directory");
match err {
cc_switch_lib::AppError::Io { path, .. } => {
assert!(
path.ends_with("config.toml"),
"io error path should point to config.toml"
);
}
cc_switch_lib::AppError::IoContext { context, .. } => {
assert!(
context.contains("config.toml"),
"error context should mention config path"
);
}
other => panic!("unexpected error variant: {other:?}"),
}
let stored = std::fs::read_to_string(&auth_path).expect("read existing auth");
assert!(
stored.contains("legacy"),
"auth.json should roll back to legacy content"
);
assert!(
std::fs::metadata(&config_path)
.expect("config path metadata")
.is_dir(),
"config path should remain a directory after failure"
);
}
#[test]
fn import_from_codex_adds_servers_from_mcp_servers_table() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -533,3 +618,56 @@ fn create_backup_generates_snapshot_file() {
"backup content should match original config"
);
}
#[test]
fn create_backup_retains_only_latest_entries() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let config_dir = home.join(".cc-switch");
let config_path = config_dir.join("config.json");
fs::create_dir_all(&config_dir).expect("prepare config dir");
fs::write(&config_path, r#"{"version":3}"#).expect("write config file");
let backups_dir = config_dir.join("backups");
fs::create_dir_all(&backups_dir).expect("create backups dir");
for idx in 0..12 {
let manual = backups_dir.join(format!("manual_{idx:02}.json"));
fs::write(&manual, format!("{{\"idx\":{idx}}}")).expect("seed manual backup");
}
std::thread::sleep(std::time::Duration::from_secs(1));
let latest_backup_id = create_backup(&config_path).expect("create backup with cleanup");
assert!(
!latest_backup_id.is_empty(),
"backup id should not be empty when config exists"
);
let entries: Vec<_> = fs::read_dir(&backups_dir)
.expect("read backups dir")
.filter_map(|entry| entry.ok())
.collect();
assert!(
entries.len() <= 10,
"expected backups to be trimmed to at most 10 files, got {}",
entries.len()
);
let latest_path = backups_dir.join(format!("{latest_backup_id}.json"));
assert!(
latest_path.exists(),
"latest backup {} should be preserved",
latest_path.display()
);
// 进一步确认保留的条目包含一些历史文件,说明清理逻辑仅裁剪多余部分
let manual_kept = entries
.iter()
.filter_map(|entry| entry.file_name().into_string().ok())
.any(|name| name.starts_with("manual_"));
assert!(
manual_kept,
"cleanup should keep part of the older backups to maintain history"
);
}

View File

@@ -0,0 +1,159 @@
use serde_json::json;
use cc_switch_lib::{
get_codex_auth_path, get_codex_config_path, read_json_file, switch_provider_test_hook,
write_codex_live_atomic, AppState, AppType, MultiAppConfig, Provider,
};
#[path = "support.rs"]
mod support;
use support::{ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn switch_provider_updates_codex_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"});
let legacy_config = r#"[mcp_servers.legacy]
type = "stdio"
command = "echo"
"#;
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
.expect("seed existing codex live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": "stale-config"
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Latest".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"[mcp_servers.latest]
type = "stdio"
command = "say"
"#
}),
None,
),
);
}
config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo"
}
}),
);
let app_state = AppState {
config: std::sync::Mutex::new(config),
};
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
.expect("switch provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.unwrap_or(""),
"fresh-key",
"live auth.json should reflect new provider"
);
let config_text =
std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
);
let locked = app_state
.config
.lock()
.expect("lock config after switch");
let manager = locked
.get_manager(&AppType::Codex)
.expect("codex manager after switch");
assert_eq!(manager.current, "new-provider", "current provider updated");
let new_provider = manager
.providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(
new_config_text, config_text,
"provider config snapshot should match live file"
);
let legacy = manager
.providers
.get("old-provider")
.expect("legacy provider still exists");
let legacy_auth_value = legacy
.settings_config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
legacy_auth_value, "legacy-key",
"previous provider should be backfilled with live auth"
);
}
#[test]
fn switch_provider_missing_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
config
.get_manager_mut(&AppType::Claude)
.expect("claude manager")
.current = "does-not-exist".to_string();
let app_state = AppState {
config: std::sync::Mutex::new(config),
};
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
.expect_err("switching to a missing provider should fail");
assert!(
err.to_string().contains("供应商不存在"),
"error message should mention missing provider"
);
}

View File

@@ -0,0 +1,47 @@
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use cc_switch_lib::{update_settings, AppSettings};
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
pub fn ensure_test_home() -> &'static Path {
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let base = std::env::temp_dir().join("cc-switch-test-home");
if base.exists() {
let _ = std::fs::remove_dir_all(&base);
}
std::fs::create_dir_all(&base).expect("create test home");
std::env::set_var("HOME", &base);
#[cfg(windows)]
std::env::set_var("USERPROFILE", &base);
base
})
.as_path()
}
/// 清理测试目录中生成的配置文件与缓存。
pub fn reset_test_fs() {
let home = ensure_test_home();
for sub in [".claude", ".codex", ".cc-switch"] {
let path = home.join(sub);
if path.exists() {
if let Err(err) = std::fs::remove_dir_all(&path) {
eprintln!("failed to clean {}: {}", path.display(), err);
}
}
}
let claude_json = home.join(".claude.json");
if claude_json.exists() {
let _ = std::fs::remove_file(&claude_json);
}
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
let _ = update_settings(AppSettings::default());
}
/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。
pub fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
MUTEX.get_or_init(|| Mutex::new(()))
}