Files
cc-switch/src-tauri/tests/provider_service.rs
Jason e8bf2c4090 Restore template Codex provider id when backfilling live config
Backfill writes the current Codex live config back to the previous
provider's stored template after a switch. Because the live file now
carries a normalized stable model_provider id, the previous provider's
template would lose its own provider-specific id (and any matching
[profiles.*] references) on every subsequent switch.

Reverse the normalization at backfill time by rewriting model_provider,
the active model_providers section, and matching profile references back
to the template's original id.
2026-04-30 09:50:31 +08:00

1149 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::<String>()
.to_lowercase()
}
#[test]
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
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 = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"includeCoAuthoredBy": false,
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
}
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
.expect("migrate legacy common config");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get providers after migration");
let provider = providers
.get("legacy-provider")
.expect("legacy provider exists");
assert_eq!(
provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled),
Some(true),
"historical provider should be explicitly marked as using common config"
);
assert!(
provider
.settings_config
.get("includeCoAuthoredBy")
.is_none(),
"common config fields should be stripped from provider storage after migration"
);
assert_eq!(
provider
.settings_config
.get("env")
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("legacy-key"),
"provider-specific auth should remain untouched"
);
}
#[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,
opencode: false,
hermes: 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 provider_service_switch_codex_preserves_live_model_provider_id_for_history() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
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(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "stale"},
"config": legacy_config
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
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 config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"live Codex model_provider should stay stable so resume history remains visible"
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers table exists");
assert!(
model_providers.get("aihubmix").is_none(),
"target provider-specific id should be rewritten in live config"
);
assert_eq!(
model_providers
.get("rightcode")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
"stable provider id should point at the newly selected supplier endpoint"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_config_text = providers
.get("new-provider")
.expect("new provider exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
new_config_text.contains("[model_providers.aihubmix]"),
"stored provider template should remain provider-specific"
);
}
#[test]
fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
let provider_a_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&legacy_auth, Some(provider_a_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 = "provider-a".to_string();
manager.providers.insert(
"provider-a".to_string(),
Provider::with_id(
"provider-a".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": provider_a_config
}),
None,
),
);
manager.providers.insert(
"provider-b".to_string(),
Provider::with_id(
"provider-b".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "aihubmix-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
profile = "work"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
[profiles.work]
model_provider = "aihubmix"
model = "gpt-5.4"
"#
}),
None,
),
);
manager.providers.insert(
"provider-c".to_string(),
Provider::with_id(
"provider-c".to_string(),
"Vendor C".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "vendor-c-key"},
"config": r#"model_provider = "vendor_c"
model = "gpt-5.4"
[model_providers.vendor_c]
name = "Vendor C"
base_url = "https://vendor-c.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "provider-b")
.expect("switch to provider b should succeed");
ProviderService::switch(&state, AppType::Codex, "provider-c")
.expect("switch to provider c should succeed");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switches");
let provider_b_config = providers
.get("provider-b")
.expect("provider b exists")
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("provider b config");
let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config");
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix"),
"backfill should restore provider b's storage-specific model_provider id"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"provider b should keep its own model_providers table after backfill"
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("aihubmix"),
"profile overrides should be restored to provider b's storage-specific id"
);
}
#[test]
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
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 = "current-provider".to_string();
let mut provider = Provider::with_id(
"current-provider".to_string(),
"Current".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "real-token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager
.providers
.insert("current-provider".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5000",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
futures::executor::block_on(state.db.save_live_backup("claude", "{\"env\":{}}"))
.expect("seed live backup");
let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app("claude"))
.expect("get proxy config");
proxy_config.enabled = true;
futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))
.expect("enable takeover");
ProviderService::sync_current_provider_for_app(&state, AppType::Claude)
.expect("sync current provider should succeed");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after sync");
assert_eq!(
live_after, taken_over_live,
"sync should not overwrite live config while takeover is active"
);
let backup = futures::executor::block_on(state.db.get_live_backup("claude"))
.expect("get live backup")
.expect("backup exists");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup value");
assert_eq!(
backup_value
.get("includeCoAuthoredBy")
.and_then(|v| v.as_bool()),
Some(false),
"restore backup should receive the updated effective config"
);
assert_eq!(
backup_value
.get("env")
.and_then(|v| v.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(|v| v.as_str()),
Some("real-token"),
"restore backup should preserve the provider token rather than proxy placeholder"
);
}
#[test]
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
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");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
.expect("mark snippet explicitly cleared");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract eligibility"),
"explicitly cleared snippets should block auto-extraction"
);
state
.db
.set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string()))
.expect("set snippet");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), false)
.expect("clear explicit-empty marker");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract after snippet saved"),
"existing snippets should also block auto-extraction"
);
}
#[test]
fn legacy_common_config_migration_flag_roundtrip() {
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");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("initial migration flag"),
"migration flag should default to false"
);
state
.db
.set_legacy_common_config_migrated(true)
.expect("set migration flag");
assert!(
state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag"),
"migration flag should persist once set"
);
state
.db
.set_legacy_common_config_migrated(false)
.expect("clear migration flag");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag after clear"),
"migration flag should be removable for tests/debugging"
);
}
#[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_preserves_env_vars() {
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": {
"GEMINI_MODEL": "gemini-2.5-pro"
}
}),
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");
// Verify env vars are preserved in ~/.gemini/.env
let env_path = home.join(".gemini").join(".env");
assert!(
env_path.exists(),
"Gemini .env should exist at {}",
env_path.display()
);
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
assert!(
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
);
// Verify OAuth security flag is still set correctly
let gemini_settings = home.join(".gemini").join("settings.json");
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"),
"OAuth security flag should still be set"
);
}
#[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:?}"),
}
}