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.
828 lines
28 KiB
Rust
828 lines
28 KiB
Rust
//! 项目 Profile 快照/应用的端到端集成测试
|
||
//!
|
||
//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。
|
||
|
||
use std::fs;
|
||
|
||
use serde_json::json;
|
||
|
||
use cc_switch_lib::{
|
||
AppType, InstalledSkill, McpServer, McpService, NewProviderAggregate, ProfilePayload,
|
||
ProfileScope, ProfileService, Prompt, PromptService, Provider, ProviderService, SkillApps,
|
||
SkillService,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use support::{create_test_state, ensure_test_home, new_provider_input, reset_test_fs, test_mutex};
|
||
|
||
fn claude_provider(id: &str, token: &str) -> Provider {
|
||
Provider::with_id(
|
||
id.to_string(),
|
||
id.to_uppercase(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": token,
|
||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||
}
|
||
}),
|
||
None,
|
||
)
|
||
}
|
||
|
||
/// Claude Desktop 供应商:无 meta 时默认 Direct 模式,只要求 env 里有 token + base_url
|
||
fn desktop_provider(id: &str, token: &str) -> Provider {
|
||
Provider::with_id(
|
||
id.to_string(),
|
||
id.to_uppercase(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": token,
|
||
"ANTHROPIC_BASE_URL": "https://desktop.test"
|
||
}
|
||
}),
|
||
None,
|
||
)
|
||
}
|
||
|
||
fn mcp_server(id: &str, claude_enabled: bool) -> McpServer {
|
||
serde_json::from_value(json!({
|
||
"id": id,
|
||
"name": id,
|
||
"server": { "command": "echo", "args": [] },
|
||
"apps": { "claude": claude_enabled }
|
||
}))
|
||
.expect("construct mcp server")
|
||
}
|
||
|
||
fn prompt(id: &str, enabled: bool) -> Prompt {
|
||
Prompt {
|
||
id: id.to_string(),
|
||
name: id.to_uppercase(),
|
||
content: format!("# prompt {id}\n"),
|
||
description: None,
|
||
enabled,
|
||
created_at: Some(1_000),
|
||
updated_at: Some(1_000),
|
||
}
|
||
}
|
||
|
||
fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill {
|
||
InstalledSkill {
|
||
id: id.to_string(),
|
||
name: id.to_string(),
|
||
description: None,
|
||
directory: directory.to_string(),
|
||
repo_owner: None,
|
||
repo_name: None,
|
||
repo_branch: None,
|
||
readme_url: None,
|
||
apps: SkillApps {
|
||
claude: claude_enabled,
|
||
..Default::default()
|
||
},
|
||
installed_at: 1_000,
|
||
content_hash: None,
|
||
updated_at: 0,
|
||
}
|
||
}
|
||
|
||
fn write_ssot_skill(directory: &str) {
|
||
let dir = SkillService::get_ssot_dir()
|
||
.expect("resolve skills SSOT dir")
|
||
.join(directory);
|
||
fs::create_dir_all(&dir).expect("create skill dir");
|
||
fs::write(
|
||
dir.join("SKILL.md"),
|
||
format!("---\nname: {directory}\ndescription: Test skill\n---\n"),
|
||
)
|
||
.expect("write SKILL.md");
|
||
}
|
||
|
||
#[test]
|
||
fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
||
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");
|
||
|
||
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::Claude,
|
||
new_provider_input(claude_provider("p1", "key-1")),
|
||
false,
|
||
)
|
||
.expect("create provider p1");
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::Claude,
|
||
new_provider_input(claude_provider("p2", "key-2")),
|
||
false,
|
||
)
|
||
.expect("create provider p2");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||
.expect("set current provider p1");
|
||
|
||
// Claude Desktop 只有供应商一个活跃维度(MCP/Skills/Prompt 对它不适用)
|
||
for provider in [
|
||
desktop_provider("d1", "dk-1"),
|
||
desktop_provider("d2", "dk-2"),
|
||
] {
|
||
state
|
||
.db
|
||
.create_provider(
|
||
NewProviderAggregate::from_input(
|
||
AppType::ClaudeDesktop.as_str(),
|
||
new_provider_input(provider),
|
||
)
|
||
.expect("build typed desktop create"),
|
||
)
|
||
.expect("create desktop provider");
|
||
}
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||
.expect("set current desktop provider d1");
|
||
|
||
// 让 live settings.json 与 p1 一致(switch_normal 回填需要)
|
||
let claude_dir = home.join(".claude");
|
||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||
fs::write(
|
||
claude_dir.join("settings.json"),
|
||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||
.expect("serialize p1 settings"),
|
||
)
|
||
.expect("seed live settings.json");
|
||
|
||
state
|
||
.db
|
||
.save_mcp_server(&mcp_server("m1", true))
|
||
.expect("save mcp m1");
|
||
state
|
||
.db
|
||
.save_mcp_server(&mcp_server("m2", false))
|
||
.expect("save mcp m2");
|
||
|
||
write_ssot_skill("test-skill");
|
||
state
|
||
.db
|
||
.save_skill(&installed_skill("local:test-skill", "test-skill", true))
|
||
.expect("save skill");
|
||
|
||
state
|
||
.db
|
||
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
|
||
.expect("save prompt pr1");
|
||
state
|
||
.db
|
||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||
.expect("save prompt pr2");
|
||
|
||
// ---- 保存项目 A(在 Claude 页新建:只拍 Claude 当前状态)----
|
||
let profile_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
|
||
.expect("create profile A");
|
||
let payload: ProfilePayload =
|
||
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
|
||
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
||
assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()]));
|
||
assert_eq!(
|
||
payload.skills.claude,
|
||
Some(vec!["local:test-skill".to_string()])
|
||
);
|
||
assert_eq!(payload.prompts.claude.as_deref(), Some("pr1"));
|
||
assert_eq!(
|
||
payload.providers.codex, None,
|
||
"codex side not captured when creating from the claude group"
|
||
);
|
||
assert_eq!(payload.mcp.codex, None, "uncaptured side stays None");
|
||
assert_eq!(
|
||
payload.providers.claude_desktop, None,
|
||
"claude desktop has its own profile scope"
|
||
);
|
||
|
||
// ---- 改动全部四类配置(走真实切换路径)----
|
||
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
|
||
// Desktop 现在有自己的项目分组;Claude 分组 apply 不应再影响 Desktop
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2");
|
||
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
|
||
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
|
||
SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false)
|
||
.expect("disable skill");
|
||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||
|
||
// ---- 应用项目 A(Claude 组):只复原 Claude 侧 ----
|
||
let (warnings, _) = ProfileService::apply(&state, &profile_a.id, ProfileScope::Claude)
|
||
.expect("apply profile A");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
let current = state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1");
|
||
|
||
// Claude 分组不再管理 Desktop:apply 后 Desktop 保持切换前的状态不变。
|
||
// macOS/Windows 上上面已切到 d2;Linux(CI)不支持 Desktop 切换、那行被 cfg 门控
|
||
// 编译剔除,Desktop 仍是种子值 d1。两种情况都验证 claude-scope apply 不会动 Desktop。
|
||
let current_desktop = state
|
||
.db
|
||
.get_current_provider(AppType::ClaudeDesktop.as_str())
|
||
.expect("get current desktop provider");
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
let expected_desktop = "d2";
|
||
#[cfg(not(any(target_os = "macos", windows)))]
|
||
let expected_desktop = "d1";
|
||
assert_eq!(
|
||
current_desktop.as_deref(),
|
||
Some(expected_desktop),
|
||
"desktop provider untouched by claude-scope apply"
|
||
);
|
||
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled");
|
||
assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled");
|
||
|
||
let skills = state.db.get_all_installed_skills().expect("get skills");
|
||
assert!(
|
||
skills.get("local:test-skill").expect("skill").apps.claude,
|
||
"skill re-enabled"
|
||
);
|
||
|
||
let prompts = state
|
||
.db
|
||
.get_prompts(AppType::Claude.as_str())
|
||
.expect("get prompts");
|
||
assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled");
|
||
assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled");
|
||
|
||
let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md");
|
||
assert_eq!(
|
||
live_prompt,
|
||
prompt("pr1", true).content,
|
||
"live memory file restored"
|
||
);
|
||
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("claude")
|
||
.expect("get current profile id")
|
||
.as_deref(),
|
||
Some(profile_a.id.as_str()),
|
||
"profile A marked as current for claude scope"
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("codex")
|
||
.expect("get codex current profile id"),
|
||
None,
|
||
"codex scope marker untouched by claude-group apply"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_profile_sides_are_isolated_and_mergeable() {
|
||
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");
|
||
|
||
// 种子:Claude 侧有当前供应商 + 启用的 MCP
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::Claude,
|
||
new_provider_input(claude_provider("p1", "key-1")),
|
||
false,
|
||
)
|
||
.expect("create provider p1");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||
.expect("set current provider p1");
|
||
let claude_dir = home.join(".claude");
|
||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||
fs::write(
|
||
claude_dir.join("settings.json"),
|
||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||
.expect("serialize p1 settings"),
|
||
)
|
||
.expect("seed live settings.json");
|
||
state
|
||
.db
|
||
.save_mcp_server(&mcp_server("m1", true))
|
||
.expect("save mcp m1");
|
||
|
||
// 在 Codex 页新建项目:快照不应捕获 Claude 侧的任何状态
|
||
let project = ProfileService::create(&state, "Shared Project", ProfileScope::Codex)
|
||
.expect("create project from codex tab");
|
||
let payload: ProfilePayload =
|
||
serde_json::from_str(&project.payload).expect("parse project payload");
|
||
assert_eq!(
|
||
payload.providers.claude, None,
|
||
"claude slot not captured by codex-side snapshot"
|
||
);
|
||
assert_eq!(payload.mcp.claude, None);
|
||
assert_eq!(payload.providers.claude_desktop, None);
|
||
assert_eq!(payload.mcp.codex, Some(vec![]), "codex side captured");
|
||
|
||
// 按 Codex 组应用:只动 codex 组的 current 标记,Claude 侧原样不动
|
||
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Codex)
|
||
.expect("apply project on codex side");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get claude current provider")
|
||
.as_deref(),
|
||
Some("p1"),
|
||
"claude provider untouched"
|
||
);
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(
|
||
servers.get("m1").expect("m1").apps.claude,
|
||
"claude MCP untouched"
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("codex")
|
||
.expect("get codex current profile id")
|
||
.as_deref(),
|
||
Some(project.id.as_str())
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("claude")
|
||
.expect("get claude current profile id"),
|
||
None,
|
||
"claude scope marker untouched by codex-side apply"
|
||
);
|
||
|
||
// 同一共享项目在 Claude 页应用:该侧未拍过快照 → 不动配置、标记 current、返回提示
|
||
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude)
|
||
.expect("apply project on claude side");
|
||
assert_eq!(warnings.len(), 1, "uncaptured side yields one hint");
|
||
assert!(warnings[0].contains("no claude configuration captured"));
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(
|
||
servers.get("m1").expect("m1").apps.claude,
|
||
"claude MCP still untouched by uncaptured apply"
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("claude")
|
||
.expect("get claude current profile id")
|
||
.as_deref(),
|
||
Some(project.id.as_str()),
|
||
"claude side now bound to the shared project"
|
||
);
|
||
|
||
// 在 Claude 页"以当前状态更新":补拍 claude 侧,codex 侧快照原样保留
|
||
let updated =
|
||
ProfileService::update(&state, &project.id, None, true, Some(ProfileScope::Claude))
|
||
.expect("resnapshot claude side");
|
||
let payload: ProfilePayload =
|
||
serde_json::from_str(&updated.payload).expect("parse updated payload");
|
||
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
||
assert_eq!(payload.mcp.claude, Some(vec!["m1".to_string()]));
|
||
assert_eq!(
|
||
payload.mcp.codex,
|
||
Some(vec![]),
|
||
"codex side snapshot preserved by claude-side resnapshot"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn profile_apply_reports_dangling_references_and_continues() {
|
||
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
|
||
.save_mcp_server(&mcp_server("m1", false))
|
||
.expect("save mcp m1");
|
||
|
||
// 手工构造引用了不存在资源的 payload
|
||
let payload = json!({
|
||
"providers": { "claude": "ghost-provider" },
|
||
"mcp": { "claude": ["m1", "ghost-mcp"] },
|
||
"skills": { "claude": ["ghost-skill"] },
|
||
"prompts": { "claude": "ghost-prompt" }
|
||
});
|
||
let profile = cc_switch_lib::Profile {
|
||
id: "dangling-test".to_string(),
|
||
name: "Dangling".to_string(),
|
||
payload: payload.to_string(),
|
||
sort_order: None,
|
||
created_at: Some(1_000),
|
||
updated_at: Some(1_000),
|
||
};
|
||
state.db.save_profile(&profile).expect("save profile");
|
||
|
||
let (warnings, _) = ProfileService::apply(&state, "dangling-test", ProfileScope::Claude)
|
||
.expect("apply succeeds");
|
||
assert_eq!(
|
||
warnings.len(),
|
||
4,
|
||
"each dangling reference yields one warning: {warnings:?}"
|
||
);
|
||
|
||
// 有效条目照常生效:m1 被启用
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(
|
||
servers.get("m1").expect("m1").apps.claude,
|
||
"m1 enabled despite warnings"
|
||
);
|
||
|
||
// best-effort 完成后仍标记为所属分组的当前项目
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("claude")
|
||
.expect("get current profile id")
|
||
.as_deref(),
|
||
Some("dangling-test")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clear_current_profile_only_clears_scoped_marker() {
|
||
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_current_profile_id("claude", Some("claude-profile"))
|
||
.expect("set claude current profile");
|
||
state
|
||
.db
|
||
.set_current_profile_id("codex", Some("codex-profile"))
|
||
.expect("set codex current profile");
|
||
|
||
// 清除 claude 组不影响 codex 组
|
||
state
|
||
.db
|
||
.set_current_profile_id("claude", None)
|
||
.expect("clear claude current profile");
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("claude")
|
||
.expect("get claude current profile id"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id("codex")
|
||
.expect("get codex current profile id")
|
||
.as_deref(),
|
||
Some("codex-profile")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switching_profile_autosaves_previous_profile_state() {
|
||
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");
|
||
|
||
// ---- 种子:Claude 侧两套供应商 / MCP / Prompt ----
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::Claude,
|
||
new_provider_input(claude_provider("p1", "key-1")),
|
||
false,
|
||
)
|
||
.expect("create provider p1");
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::Claude,
|
||
new_provider_input(claude_provider("p2", "key-2")),
|
||
false,
|
||
)
|
||
.expect("create provider p2");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||
.expect("set current provider p1");
|
||
|
||
let claude_dir = home.join(".claude");
|
||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||
fs::write(
|
||
claude_dir.join("settings.json"),
|
||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||
.expect("serialize p1 settings"),
|
||
)
|
||
.expect("seed live settings.json");
|
||
|
||
state
|
||
.db
|
||
.save_mcp_server(&mcp_server("m1", true))
|
||
.expect("save mcp m1");
|
||
state
|
||
.db
|
||
.save_mcp_server(&mcp_server("m2", false))
|
||
.expect("save mcp m2");
|
||
|
||
state
|
||
.db
|
||
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
|
||
.expect("save prompt pr1");
|
||
state
|
||
.db
|
||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||
.expect("save prompt pr2");
|
||
|
||
// ---- Project A:状态 X(p1 / m1 / pr1)----
|
||
let project_a = ProfileService::create(&state, "Project A", ProfileScope::Claude)
|
||
.expect("create project A");
|
||
let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
|
||
.expect("apply project A");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
// ---- 在 A 下改到状态 Y(p2 / m2 / pr2),然后据此创建 Project B ----
|
||
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
|
||
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
|
||
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
|
||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||
|
||
let project_b = ProfileService::create(&state, "Project B", ProfileScope::Claude)
|
||
.expect("create project B");
|
||
|
||
// ---- 从 A 切换到 B:自动把当前状态 Y 保存到 A,再加载 B 的 Y ----
|
||
let (warnings, _) = ProfileService::apply(&state, &project_b.id, ProfileScope::Claude)
|
||
.expect("switch to project B");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider")
|
||
.as_deref(),
|
||
Some("p2"),
|
||
"provider switched to p2"
|
||
);
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(!servers.get("m1").expect("m1").apps.claude, "m1 disabled");
|
||
assert!(servers.get("m2").expect("m2").apps.claude, "m2 enabled");
|
||
let prompts = state
|
||
.db
|
||
.get_prompts(AppType::Claude.as_str())
|
||
.expect("get prompts");
|
||
assert!(!prompts.get("pr1").expect("pr1").enabled, "pr1 disabled");
|
||
assert!(prompts.get("pr2").expect("pr2").enabled, "pr2 enabled");
|
||
|
||
// Project A 被自动保存为离开时的状态 Y
|
||
let saved_a = state
|
||
.db
|
||
.get_profile(&project_a.id)
|
||
.expect("get project A")
|
||
.expect("project A exists");
|
||
let payload_a: ProfilePayload =
|
||
serde_json::from_str(&saved_a.payload).expect("parse project A payload");
|
||
assert_eq!(payload_a.providers.claude.as_deref(), Some("p2"));
|
||
assert_eq!(payload_a.mcp.claude, Some(vec!["m2".to_string()]));
|
||
assert_eq!(payload_a.prompts.claude.as_deref(), Some("pr2"));
|
||
|
||
// ---- 在 B 下改回状态 X,再切换回 A ----
|
||
ProviderService::switch(&state, AppType::Claude, "p1").expect("switch to p1");
|
||
McpService::toggle_app(&state, "m1", AppType::Claude, true).expect("enable m1");
|
||
McpService::toggle_app(&state, "m2", AppType::Claude, false).expect("disable m2");
|
||
PromptService::enable_prompt(&state, AppType::Claude, "pr1").expect("enable pr1");
|
||
|
||
let (warnings, _) = ProfileService::apply(&state, &project_a.id, ProfileScope::Claude)
|
||
.expect("switch back to project A");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
// 切回 A 时:先自动保存 B 为状态 X,再加载 A 的上次离开状态 Y
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider")
|
||
.as_deref(),
|
||
Some("p2"),
|
||
"project A restored to the state when we left it (p2)"
|
||
);
|
||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||
assert!(
|
||
!servers.get("m1").expect("m1").apps.claude,
|
||
"m1 stays disabled"
|
||
);
|
||
assert!(
|
||
servers.get("m2").expect("m2").apps.claude,
|
||
"m2 stays enabled"
|
||
);
|
||
let prompts = state
|
||
.db
|
||
.get_prompts(AppType::Claude.as_str())
|
||
.expect("get prompts");
|
||
assert!(
|
||
!prompts.get("pr1").expect("pr1").enabled,
|
||
"pr1 stays disabled"
|
||
);
|
||
assert!(
|
||
prompts.get("pr2").expect("pr2").enabled,
|
||
"pr2 stays enabled"
|
||
);
|
||
|
||
// Project B 被自动保存为离开时的状态 X
|
||
let saved_b = state
|
||
.db
|
||
.get_profile(&project_b.id)
|
||
.expect("get project B")
|
||
.expect("project B exists");
|
||
let payload_b: ProfilePayload =
|
||
serde_json::from_str(&saved_b.payload).expect("parse project B payload");
|
||
assert_eq!(payload_b.providers.claude.as_deref(), Some("p1"));
|
||
assert_eq!(payload_b.mcp.claude, Some(vec!["m1".to_string()]));
|
||
assert_eq!(payload_b.prompts.claude.as_deref(), Some("pr1"));
|
||
}
|
||
|
||
#[test]
|
||
fn profile_switch_auto_disables_takeover_before_apply() {
|
||
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");
|
||
|
||
// 使用临时端口,避免测试机器端口冲突
|
||
futures::executor::block_on(async {
|
||
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
|
||
proxy_config.listen_port = 0;
|
||
state
|
||
.db
|
||
.update_proxy_config(proxy_config)
|
||
.await
|
||
.expect("set ephemeral proxy port");
|
||
});
|
||
|
||
// ---- 两个 Claude 供应商:custom1 与 custom2 ----
|
||
let mut custom1 = claude_provider("custom1", "custom-key-1");
|
||
custom1.category = Some("custom".to_string());
|
||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom1), false)
|
||
.expect("create custom1 provider");
|
||
|
||
let mut custom2 = claude_provider("custom2", "custom-key-2");
|
||
custom2.category = Some("custom".to_string());
|
||
ProviderService::add(&state, AppType::Claude, new_provider_input(custom2), false)
|
||
.expect("create custom2 provider");
|
||
|
||
// 初始状态:custom1 + 代理接管
|
||
ProviderService::switch(&state, AppType::Claude, "custom1").expect("switch to custom1");
|
||
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
|
||
rt.block_on(state.proxy_service.set_takeover_for_app("claude", true))
|
||
.expect("enable claude takeover");
|
||
|
||
let (proxy_enabled_before, _) = state.db.get_proxy_flags_sync("claude");
|
||
assert!(
|
||
proxy_enabled_before,
|
||
"takeover should be active before apply"
|
||
);
|
||
|
||
// ---- 构造一个目标为 custom2 的项目快照 ----
|
||
let project = ProfileService::create(&state, "Custom2 Project", ProfileScope::Claude)
|
||
.expect("create project");
|
||
let mut project = state
|
||
.db
|
||
.get_profile(&project.id)
|
||
.expect("get project")
|
||
.expect("project exists");
|
||
let mut payload: ProfilePayload =
|
||
serde_json::from_str(&project.payload).expect("parse project payload");
|
||
payload.providers.claude = Some("custom2".to_string());
|
||
project.payload = serde_json::to_string(&payload).expect("serialize payload");
|
||
state
|
||
.db
|
||
.save_profile(&project)
|
||
.expect("save updated project");
|
||
|
||
// ---- 应用项目:应无条件自动关闭接管,再切换到 custom2 ----
|
||
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::Claude)
|
||
.expect("apply custom2 project");
|
||
assert!(
|
||
warnings.is_empty(),
|
||
"switching project should not warn: {warnings:?}"
|
||
);
|
||
|
||
// 接管已关闭
|
||
let (proxy_enabled_after, _) = state.db.get_proxy_flags_sync("claude");
|
||
assert!(
|
||
!proxy_enabled_after,
|
||
"proxy takeover should be auto-disabled before applying profile"
|
||
);
|
||
|
||
// 当前供应商已切到 custom2
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider")
|
||
.as_deref(),
|
||
Some("custom2"),
|
||
"current provider should be custom2"
|
||
);
|
||
|
||
// live 配置应指向 custom2 的真实 endpoint,而非代理地址
|
||
let settings_path = home.join(".claude/settings.json");
|
||
let settings: serde_json::Value =
|
||
serde_json::from_str(&fs::read_to_string(&settings_path).expect("read settings"))
|
||
.expect("parse settings");
|
||
let base_url = settings
|
||
.get("env")
|
||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||
.and_then(|v| v.as_str());
|
||
assert_eq!(
|
||
base_url,
|
||
Some("https://api.test"),
|
||
"live config should point to real endpoint after auto-disable"
|
||
);
|
||
}
|
||
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
#[test]
|
||
fn claude_desktop_profile_scope_is_independent() {
|
||
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");
|
||
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::ClaudeDesktop,
|
||
new_provider_input(desktop_provider("d1", "dk-1")),
|
||
false,
|
||
)
|
||
.expect("create desktop provider d1");
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::ClaudeDesktop,
|
||
new_provider_input(desktop_provider("d2", "dk-2")),
|
||
false,
|
||
)
|
||
.expect("create desktop provider d2");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::ClaudeDesktop.as_str(), "d1")
|
||
.expect("set current desktop provider d1");
|
||
|
||
// 在 Desktop 页新建项目:只拍 Desktop 供应商
|
||
let project = ProfileService::create(&state, "Desktop Project", ProfileScope::ClaudeDesktop)
|
||
.expect("create desktop profile");
|
||
let payload: ProfilePayload =
|
||
serde_json::from_str(&project.payload).expect("parse desktop payload");
|
||
assert_eq!(payload.providers.claude_desktop.as_deref(), Some("d1"));
|
||
assert_eq!(payload.providers.claude, None, "claude slot untouched");
|
||
assert_eq!(payload.providers.codex, None, "codex slot untouched");
|
||
|
||
// 切到 d2
|
||
ProviderService::switch(&state, AppType::ClaudeDesktop, "d2").expect("switch desktop to d2");
|
||
|
||
// 应用 Desktop 项目:恢复 d1
|
||
let (warnings, _) = ProfileService::apply(&state, &project.id, ProfileScope::ClaudeDesktop)
|
||
.expect("apply desktop profile");
|
||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::ClaudeDesktop.as_str())
|
||
.expect("get current desktop provider")
|
||
.as_deref(),
|
||
Some("d1"),
|
||
"desktop provider restored by desktop-scope apply"
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_profile_id(ProfileScope::ClaudeDesktop.as_str())
|
||
.expect("get desktop current profile id")
|
||
.as_deref(),
|
||
Some(project.id.as_str()),
|
||
"desktop scope marker set"
|
||
);
|
||
}
|