use serde_json::json; use cc_switch_lib::{ get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService, }; #[path = "support.rs"] mod support; use support::{ create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex, }; fn sanitize_provider_name(name: &str) -> String { name.chars() .map(|c| match c { '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-', _ => c, }) .collect::() .to_lowercase() } #[test] fn provider_service_switch_codex_updates_live_and_config() { 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 initial_config = MultiAppConfig::default(); { let manager = initial_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, ), ); } // 使用新的统一 MCP 结构(v3.7.0+) let servers = initial_config .mcp .servers .get_or_insert_with(Default::default); servers.insert( "echo-server".into(), McpServer { id: "echo-server".into(), name: "Echo Server".into(), server: json!({ "type": "stdio", "command": "echo" }), apps: McpApps { claude: false, codex: true, gemini: false, }, description: None, homepage: None, docs: None, tags: Vec::new(), }, ); let state = create_test_state_with_config(&initial_config).expect("create test state"); ProviderService::switch(&state, AppType::Codex, "new-provider") .expect("switch provider should succeed"); let auth_value: serde_json::Value = read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json"); assert_eq!( auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()), Some("fresh-key"), "live auth.json should reflect new provider" ); let config_text = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); assert!( config_text.contains("mcp_servers.echo-server"), "config.toml should contain synced MCP servers" ); let current_id = state .db .get_current_provider(AppType::Codex.as_str()) .expect("read current provider after switch"); assert_eq!( current_id.as_deref(), Some("new-provider"), "current provider updated" ); let providers = state .db .get_all_providers(AppType::Codex.as_str()) .expect("read providers after switch"); let new_provider = 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(); // provider 存储的是原始配置,不包含 MCP 同步后的内容 assert!( new_config_text.contains("mcp_servers.latest"), "provider config should contain original MCP servers" ); // live 文件额外包含同步的 MCP 服务器 assert!( config_text.contains("mcp_servers.echo-server"), "live config should include synced MCP servers" ); let legacy = 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_packycode_gemini_updates_security_selected_type() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Gemini) .expect("gemini manager"); manager.current = "packy-gemini".to_string(); manager.providers.insert( "packy-gemini".to_string(), Provider::with_id( "packy-gemini".to_string(), "PackyCode".to_string(), json!({ "env": { "GEMINI_API_KEY": "pk-key", "GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com" } }), Some("https://www.packyapi.com".to_string()), ), ); } let state = create_test_state_with_config(&config).expect("create test state"); ProviderService::switch(&state, AppType::Gemini, "packy-gemini") .expect("switching to PackyCode Gemini should succeed"); // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json let settings_path = home.join(".gemini").join("settings.json"); assert!( settings_path.exists(), "Gemini settings.json should exist at {}", settings_path.display() ); let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json"); let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json after switch"); assert_eq!( value .pointer("/security/auth/selectedType") .and_then(|v| v.as_str()), Some("gemini-api-key"), "PackyCode Gemini should set security.auth.selectedType" ); } #[test] fn packycode_partner_meta_triggers_security_flag_even_without_keywords() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Gemini) .expect("gemini manager"); manager.current = "packy-meta".to_string(); let mut provider = Provider::with_id( "packy-meta".to_string(), "Generic Gemini".to_string(), json!({ "env": { "GEMINI_API_KEY": "pk-meta", "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com" } }), Some("https://example.com".to_string()), ); provider.meta = Some(ProviderMeta { partner_promotion_key: Some("packycode".to_string()), ..ProviderMeta::default() }); manager.providers.insert("packy-meta".to_string(), provider); } let state = create_test_state_with_config(&config).expect("create test state"); ProviderService::switch(&state, AppType::Gemini, "packy-meta") .expect("switching to partner meta provider should succeed"); // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json let settings_path = home.join(".gemini").join("settings.json"); assert!( settings_path.exists(), "Gemini settings.json should exist at {}", settings_path.display() ); let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json"); let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json after switch"); assert_eq!( value .pointer("/security/auth/selectedType") .and_then(|v| v.as_str()), Some("gemini-api-key"), "Partner meta should set security.auth.selectedType even without packy keywords" ); } #[test] fn switch_google_official_gemini_sets_oauth_security() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Gemini) .expect("gemini manager"); manager.current = "google-official".to_string(); let mut provider = Provider::with_id( "google-official".to_string(), "Google".to_string(), json!({ "env": {} }), Some("https://ai.google.dev".to_string()), ); provider.meta = Some(ProviderMeta { partner_promotion_key: Some("google-official".to_string()), ..ProviderMeta::default() }); manager .providers .insert("google-official".to_string(), provider); } let state = create_test_state_with_config(&config).expect("create test state"); ProviderService::switch(&state, AppType::Gemini, "google-official") .expect("switching to Google official Gemini should succeed"); // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json let gemini_settings = home.join(".gemini").join("settings.json"); assert!( gemini_settings.exists(), "Gemini settings.json should exist at {}", gemini_settings.display() ); let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings"); let gemini_value: serde_json::Value = serde_json::from_str(&gemini_raw).expect("parse gemini settings"); assert_eq!( gemini_value .pointer("/security/auth/selectedType") .and_then(|v| v.as_str()), Some("oauth-personal"), "Gemini settings json should reflect oauth-personal for Google Official" ); } #[test] fn provider_service_switch_claude_updates_live_and_state() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); let settings_path = get_claude_settings_path(); if let Some(parent) = settings_path.parent() { std::fs::create_dir_all(parent).expect("create claude settings dir"); } let legacy_live = json!({ "env": { "ANTHROPIC_API_KEY": "legacy-key" }, "workspace": { "path": "/tmp/workspace" } }); std::fs::write( &settings_path, serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"), ) .expect("seed claude live config"); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Claude) .expect("claude manager"); manager.current = "old-provider".to_string(); manager.providers.insert( "old-provider".to_string(), Provider::with_id( "old-provider".to_string(), "Legacy Claude".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "stale-key" } }), None, ), ); manager.providers.insert( "new-provider".to_string(), Provider::with_id( "new-provider".to_string(), "Fresh Claude".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "fresh-key" }, "workspace": { "path": "/tmp/new-workspace" } }), None, ), ); } let state = create_test_state_with_config(&config).expect("create test state"); ProviderService::switch(&state, AppType::Claude, "new-provider") .expect("switch provider should succeed"); let live_after: serde_json::Value = read_json_file(&settings_path).expect("read claude live settings"); assert_eq!( live_after .get("env") .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|key| key.as_str()), Some("fresh-key"), "live settings.json should reflect new provider auth" ); let providers = state .db .get_all_providers(AppType::Claude.as_str()) .expect("get all providers"); let current_id = state .db .get_current_provider(AppType::Claude.as_str()) .expect("get current provider"); assert_eq!( current_id.as_deref(), Some("new-provider"), "current provider updated" ); let legacy_provider = providers .get("old-provider") .expect("legacy provider still exists"); assert_eq!( legacy_provider.settings_config, legacy_live, "previous provider should receive backfilled live config" ); } #[test] fn provider_service_switch_missing_provider_returns_error() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); let state = create_test_state().expect("create test state"); let err = ProviderService::switch(&state, AppType::Claude, "missing") .expect_err("switching missing provider should fail"); match err { AppError::Message(msg) => { assert!( msg.contains("不存在") || msg.contains("not found"), "expected provider not found message, got {msg}" ); } other => panic!("expected Message error for provider not found, got {other:?}"), } } #[test] fn provider_service_switch_codex_missing_auth_returns_error() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Codex) .expect("codex manager"); manager.providers.insert( "invalid".to_string(), Provider::with_id( "invalid".to_string(), "Broken Codex".to_string(), json!({ "config": "[mcp_servers.test]\ncommand = \"noop\"" }), None, ), ); } let state = create_test_state_with_config(&config).expect("create test state"); let err = ProviderService::switch(&state, AppType::Codex, "invalid") .expect_err("switching should fail without auth"); match err { AppError::Config(msg) => assert!( msg.contains("auth"), "expected auth related message, got {msg}" ), other => panic!("expected config error, got {other:?}"), } } #[test] fn provider_service_delete_codex_removes_provider_and_files() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Codex) .expect("codex manager"); manager.current = "keep".to_string(); manager.providers.insert( "keep".to_string(), Provider::with_id( "keep".to_string(), "Keep".to_string(), json!({ "auth": {"OPENAI_API_KEY": "keep-key"}, "config": "" }), None, ), ); manager.providers.insert( "to-delete".to_string(), Provider::with_id( "to-delete".to_string(), "DeleteCodex".to_string(), json!({ "auth": {"OPENAI_API_KEY": "delete-key"}, "config": "" }), None, ), ); } let sanitized = sanitize_provider_name("DeleteCodex"); let codex_dir = home.join(".codex"); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); let auth_path = codex_dir.join(format!("auth-{sanitized}.json")); let cfg_path = codex_dir.join(format!("config-{sanitized}.toml")); std::fs::write(&auth_path, "{}").expect("seed auth file"); std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file"); let app_state = create_test_state_with_config(&config).expect("create test state"); ProviderService::delete(&app_state, AppType::Codex, "to-delete") .expect("delete provider should succeed"); let providers = app_state .db .get_all_providers(AppType::Codex.as_str()) .expect("get all providers"); assert!( !providers.contains_key("to-delete"), "provider entry should be removed" ); // v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml) // 删除供应商只影响数据库记录,不清理这些旧格式文件 } #[test] fn provider_service_delete_claude_removes_provider_files() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Claude) .expect("claude manager"); manager.current = "keep".to_string(); manager.providers.insert( "keep".to_string(), Provider::with_id( "keep".to_string(), "Keep".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "keep-key" } }), None, ), ); manager.providers.insert( "delete".to_string(), Provider::with_id( "delete".to_string(), "DeleteClaude".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "delete-key" } }), None, ), ); } let sanitized = sanitize_provider_name("DeleteClaude"); let claude_dir = home.join(".claude"); std::fs::create_dir_all(&claude_dir).expect("create claude dir"); let by_name = claude_dir.join(format!("settings-{sanitized}.json")); let by_id = claude_dir.join("settings-delete.json"); std::fs::write(&by_name, "{}").expect("seed settings by name"); std::fs::write(&by_id, "{}").expect("seed settings by id"); let app_state = create_test_state_with_config(&config).expect("create test state"); ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider"); let providers = app_state .db .get_all_providers(AppType::Claude.as_str()) .expect("get all providers"); assert!( !providers.contains_key("delete"), "claude provider should be removed" ); // v3.7.0+ 不再使用供应商特定文件(如 settings-*.json) // 删除供应商只影响数据库记录,不清理这些旧格式文件 } #[test] fn provider_service_delete_current_provider_returns_error() { let _guard = test_mutex().lock().expect("acquire test mutex"); reset_test_fs(); let _home = ensure_test_home(); let mut config = MultiAppConfig::default(); { let manager = config .get_manager_mut(&AppType::Claude) .expect("claude manager"); manager.current = "keep".to_string(); manager.providers.insert( "keep".to_string(), Provider::with_id( "keep".to_string(), "Keep".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "keep-key" } }), None, ), ); } let app_state = create_test_state_with_config(&config).expect("create test state"); let err = ProviderService::delete(&app_state, AppType::Claude, "keep") .expect_err("deleting current provider should fail"); match err { AppError::Localized { zh, .. } => assert!( zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"), "unexpected message: {zh}" ), AppError::Config(msg) => assert!( msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"), "unexpected message: {msg}" ), AppError::Message(msg) => assert!( msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"), "unexpected message: {msg}" ), other => panic!("expected Config/Message error, got {other:?}"), } }