mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
ac764b4818
Replace the generic provider upsert surface with strict typed create, update, rename, endpoint, and compensation operations. This app-independent prerequisite owns provider writes without depending on Pi runtime or canonical restore. The v17 migration also reserves dormant device-local ledger tables and the Pi skill bit alongside endpoint normalization. Keeping that reservation here prevents a later stacked feature from rewriting a published migration; no Pi runtime behavior is activated by this commit. Old save_provider callsite classification ========================================== Inventory authority: abandoned 5a385fc8 tree. The old definition at src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite. Production callsites: - src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates one absent aggregate; it now strict-inserts the row and initial endpoints in one transaction. - src-tauri/src/database/dao/providers.rs:638 [create] official seed first proves absence, then strict-creates; a racing insert is a conflict. - src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first proves absence, then strict-creates; it cannot overwrite an existing row. - src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new aggregate and strict-creates it; OMO is not eligible for rename. - src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used is not a Provider-row save; it now calls the exact touch endpoint operation. - src-tauri/src/services/provider/live.rs:1567 [create/update] default live import is reconciliation: read first, then strict create or strict update. - src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns strict aggregate creation and all initial endpoints. - src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key change now uses the dedicated transactional rename after eligibility checks. - src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly the existing main row after its live-file coordination. - src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit updates exactly the existing main row after resolving live ownership. - src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates exactly the existing main row and never inserts. - src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes only the existing provider's live-managed marker. - src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates only the existing current provider row. - src-tauri/src/services/provider/mod.rs:3174 [update] successful additive switch changes only the existing live-managed marker. - src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed to the dedicated sort-index state operation, not row replacement. - src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to- Claude reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to- Codex reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to- Gemini reconciliation reads the target and selects strict create or update. Required indirect ownership paths: - src-tauri/src/deeplink/provider.rs [create] the old indirect flow called ProviderService::add and then appended endpoints one by one. It now supplies every non-primary endpoint to one strict aggregate create, so hydration is complete atomically and a duplicate is zero-side-effect. - [restore] no old generic-save callsite is reclassified as restore. Exact aggregate replacement exists only as the sealed restore_provider_aggregate_on_tx compensation primitive. Test-only callsites: Every item below is classified [test]. Each is fixture setup, not a production write authority, and is migrated to a real ProviderService entry where the behavior is under test or to the cfg(test)-only typed fixture reconciler where the test merely needs pre-existing rows. - src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup. - src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup. - src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup. - src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup. - src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup. - src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create. - src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create. - src-tauri/tests/provider_commands.rs:69 [test] command fixture create. - src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
694 lines
22 KiB
Rust
694 lines
22 KiB
Rust
use serde_json::json;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use cc_switch_lib::{
|
||
get_codex_auth_path, get_codex_config_path, import_default_config_test_hook, read_json_file,
|
||
switch_provider_test_hook, write_codex_live_atomic, AppError, AppType, McpApps, McpServer,
|
||
MultiAppConfig, Provider, ProviderService,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use std::collections::HashMap;
|
||
use support::{
|
||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
||
};
|
||
|
||
fn settings_path(home: &Path) -> PathBuf {
|
||
home.join(".cc-switch").join("settings.json")
|
||
}
|
||
|
||
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
|
||
format!(
|
||
r#"[models]
|
||
default = "grok-4.5"
|
||
|
||
[model."grok-4.5"]
|
||
model = "grok-4.5"
|
||
base_url = "{endpoint}"
|
||
name = "{name}"
|
||
api_key = "{api_key}"
|
||
api_backend = "responses"
|
||
context_window = 500000
|
||
"#
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn grokbuild_import_and_switch_write_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let live_path = home.join(".grok").join("config.toml");
|
||
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
|
||
.expect("create grok config dir");
|
||
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
|
||
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::GrokBuild)
|
||
.expect("import Grok Build default provider");
|
||
|
||
let imported = state
|
||
.db
|
||
.get_provider_by_id("default", AppType::GrokBuild.as_str())
|
||
.expect("query imported provider")
|
||
.expect("imported provider exists");
|
||
assert_eq!(
|
||
imported
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str()),
|
||
Some(imported_config.as_str())
|
||
);
|
||
|
||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::GrokBuild,
|
||
new_provider_input(Provider::with_id(
|
||
"relay".to_string(),
|
||
"Relay".to_string(),
|
||
json!({ "config": next_config }),
|
||
None,
|
||
)),
|
||
false,
|
||
)
|
||
.expect("create second Grok Build provider");
|
||
|
||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||
.expect("switch Grok Build provider");
|
||
|
||
assert_eq!(
|
||
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
|
||
next_config
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::GrokBuild.as_str())
|
||
.expect("read Grok Build current provider")
|
||
.as_deref(),
|
||
Some("relay")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
assert!(
|
||
ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("check startup import eligibility"),
|
||
"empty Codex provider set should import on startup"
|
||
);
|
||
|
||
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
assert_eq!(
|
||
providers.len(),
|
||
1,
|
||
"fresh install import should create exactly one Codex provider before seeding"
|
||
);
|
||
assert!(
|
||
providers.contains_key("default"),
|
||
"fresh install import should create default provider"
|
||
);
|
||
|
||
let current_id = state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get codex current provider");
|
||
assert_eq!(current_id.as_deref(), Some("default"));
|
||
|
||
let settings: serde_json::Value = serde_json::from_str(
|
||
&std::fs::read_to_string(settings_path(home)).expect("read settings.json"),
|
||
)
|
||
.expect("parse settings.json");
|
||
assert_eq!(
|
||
settings
|
||
.get("currentProviderCodex")
|
||
.and_then(|value| value.as_str()),
|
||
Some("default"),
|
||
"live import should also sync device-local currentProviderCodex"
|
||
);
|
||
|
||
state
|
||
.db
|
||
.init_default_official_providers()
|
||
.expect("seed official providers");
|
||
let providers_after_seed = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after seed");
|
||
assert_eq!(
|
||
providers_after_seed.len(),
|
||
2,
|
||
"official seeding should add codex-official alongside imported default"
|
||
);
|
||
assert!(providers_after_seed.contains_key("codex-official"));
|
||
|
||
assert!(
|
||
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("re-check startup import eligibility"),
|
||
"subsequent startup should skip once Codex already has providers"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_accepts_config_without_auth_file() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let config_path = get_codex_config_path();
|
||
if let Some(parent) = config_path.parent() {
|
||
std::fs::create_dir_all(parent).expect("create codex config dir");
|
||
}
|
||
std::fs::write(
|
||
&config_path,
|
||
r#"model_provider = "aihubmix"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
experimental_bearer_token = "live-key"
|
||
"#,
|
||
)
|
||
.expect("seed config.toml without auth.json");
|
||
assert!(
|
||
!get_codex_auth_path().exists(),
|
||
"test should not seed auth.json"
|
||
);
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::Codex)
|
||
.expect("import codex config-only default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
let provider = providers.get("default").expect("default provider exists");
|
||
assert_eq!(
|
||
provider.settings_config.pointer("/auth"),
|
||
Some(&json!({})),
|
||
"missing auth.json should import as an empty auth object"
|
||
);
|
||
assert!(
|
||
provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
.unwrap_or_default()
|
||
.contains("experimental_bearer_token"),
|
||
"config.toml content should still be imported"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_marks_oauth_only_default_official() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"tokens": {
|
||
"id_token": "oauth-id",
|
||
"access_token": "oauth-access"
|
||
}
|
||
});
|
||
let config = r#"[mcp_servers.echo]
|
||
command = "echo"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed oauth-only codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
let provider = providers.get("default").expect("default provider exists");
|
||
|
||
assert_eq!(
|
||
provider.category.as_deref(),
|
||
Some("official"),
|
||
"OAuth-only live Codex installs should keep official behavior"
|
||
);
|
||
assert_eq!(
|
||
provider.settings_config.pointer("/auth/tokens/id_token"),
|
||
Some(&json!("oauth-id")),
|
||
"import should preserve OAuth login material"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_skips_when_only_official_seed_exists() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
state
|
||
.db
|
||
.init_default_official_providers()
|
||
.expect("seed official providers");
|
||
|
||
let providers_before = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers before restart check");
|
||
assert_eq!(
|
||
providers_before.len(),
|
||
1,
|
||
"fixture should start with only codex-official present"
|
||
);
|
||
assert!(providers_before.contains_key("codex-official"));
|
||
|
||
assert!(
|
||
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("check startup import eligibility"),
|
||
"startup should skip import when codex-official already exists"
|
||
);
|
||
|
||
let providers_after = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after restart check");
|
||
assert_eq!(
|
||
providers_after.len(),
|
||
providers_before.len(),
|
||
"skipping startup import should not grow the Codex provider set"
|
||
);
|
||
assert!(
|
||
!providers_after.contains_key("default"),
|
||
"restart path should not create a new default provider"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_updates_codex_live_and_state() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
enable_codex_official_auth_preservation();
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
|
||
// v3.7.0+: 使用统一的 MCP 结构
|
||
config.mcp.servers = Some(HashMap::new());
|
||
config.mcp.servers.as_mut().unwrap().insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".to_string(),
|
||
name: "Echo Server".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true, // 启用 Codex
|
||
gemini: false,
|
||
grokbuild: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
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(""),
|
||
"legacy-key",
|
||
"Codex provider switching should preserve the existing live auth.json"
|
||
);
|
||
|
||
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"
|
||
);
|
||
assert!(
|
||
config_text.contains("experimental_bearer_token"),
|
||
"config.toml should carry the selected provider API key as bearer token"
|
||
);
|
||
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(
|
||
current_id.as_deref(),
|
||
Some("new-provider"),
|
||
"current provider updated"
|
||
);
|
||
|
||
let providers = app_state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get all providers");
|
||
|
||
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();
|
||
// 供应商配置应该包含在 live 文件中
|
||
// 注意:live 文件还会包含 MCP 同步后的内容
|
||
assert!(
|
||
config_text.contains("mcp_servers.latest"),
|
||
"live file should contain provider's original config"
|
||
);
|
||
assert!(
|
||
new_config_text.contains("mcp_servers.latest"),
|
||
"provider snapshot should contain provider's original config"
|
||
);
|
||
|
||
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("");
|
||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||
// 这保护了用户在 live 文件中的手动修改
|
||
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 = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
|
||
.expect_err("switching to a missing provider should fail");
|
||
|
||
let err_str = err.to_string();
|
||
assert!(
|
||
err_str.contains("供应商不存在")
|
||
|| err_str.contains("Provider not found")
|
||
|| err_str.contains("missing-provider"),
|
||
"error message should mention missing provider, got: {err_str}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_updates_claude_live_and_state() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let settings_path = cc_switch_lib::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 app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
switch_provider_test_hook(&app_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 current_id = app_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 providers = app_state
|
||
.db
|
||
.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
|
||
let legacy_provider = providers
|
||
.get("old-provider")
|
||
.expect("legacy provider still exists");
|
||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||
// 这保护了用户在 live 文件中的手动修改
|
||
assert_eq!(
|
||
legacy_provider.settings_config, legacy_live,
|
||
"previous provider should be backfilled with live config"
|
||
);
|
||
|
||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||
assert_eq!(
|
||
new_provider
|
||
.settings_config
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|key| key.as_str()),
|
||
Some("fresh-key"),
|
||
"new provider snapshot should retain fresh auth"
|
||
);
|
||
|
||
// v3.7.0+ 使用 SQLite 数据库而非 config.json
|
||
// 验证数据已持久化到数据库
|
||
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||
let db_path = std::path::Path::new(&home_dir)
|
||
.join(".cc-switch")
|
||
.join("cc-switch.db");
|
||
assert!(
|
||
db_path.exists(),
|
||
"switching provider should persist to cc-switch.db"
|
||
);
|
||
|
||
// 验证当前供应商已更新
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(
|
||
current_id.as_deref(),
|
||
Some("new-provider"),
|
||
"database should record the new current provider"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||
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 app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
|
||
.expect_err("switching should fail when auth missing");
|
||
match err {
|
||
AppError::Config(msg) => assert!(
|
||
msg.contains("auth"),
|
||
"expected auth missing error message, got {msg}"
|
||
),
|
||
other => panic!("expected config error, got {other:?}"),
|
||
}
|
||
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get current provider");
|
||
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
|
||
// 但由于 live 配置写入失败,状态应该回滚
|
||
// 注意:这个行为取决于 switch_provider 的具体实现
|
||
assert!(
|
||
current_id.is_none() || current_id.as_deref() == Some("invalid"),
|
||
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_refuses_live_config_under_proxy_takeover() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
ensure_test_home();
|
||
|
||
// 接管态 Codex Live:auth 是 PROXY_MANAGED 占位符,不是用户真实配置
|
||
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
import_default_config_test_hook(&state, AppType::Codex)
|
||
.expect_err("importing a taken-over live config must fail");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers");
|
||
assert!(
|
||
providers.is_empty(),
|
||
"taken-over live import must not create providers"
|
||
);
|
||
}
|