mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
4f78451405
Replace the generic Provider save/upsert surface with strict typed create/update/rename/endpoint operations. Keep aggregate hydration read-only, preserve nullable endpoint timestamps end to end, and make service create own the initial endpoint set atomically. Restore SQL and binary backups only through UntrustedScratch, migrate and copy fixed data columns into a fresh CanonicalStage, validate the canonical result, then publish through the Backup API. The imported schema is never eligible to become the live schema. 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"
|
||
);
|
||
}
|