From d1f6c74be8f2ecb147b73e8e843e38601bec6d2e Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sun, 28 Jun 2026 17:37:58 +0800 Subject: [PATCH] fix(usage): treat usage_script credentials as explicit overrides (#4654) * fix(usage): treat usage_script credentials as explicit overrides * fix: treat usage script credentials as explicit overrides --- src-tauri/src/deeplink/provider.rs | 58 ++- src-tauri/src/deeplink/tests.rs | 148 +++++++ src-tauri/src/services/provider/mod.rs | 381 +++++++++++++++++- src/hooks/useProviderActions.ts | 3 +- src/lib/query/mutations.ts | 11 +- .../hooks/useUpdateProviderMutation.test.tsx | 127 ++++++ 6 files changed, 711 insertions(+), 17 deletions(-) create mode 100644 tests/hooks/useUpdateProviderMutation.test.tsx diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 821475ccb..d41e4654d 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -185,6 +185,48 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String { .unwrap_or_default() } +fn normalize_deeplink_api_key(api_key: &str) -> String { + api_key.trim().to_string() +} + +fn normalize_deeplink_base_url(base_url: &str) -> String { + base_url.trim().trim_end_matches('/').to_string() +} + +fn usage_api_key_override(request: &DeepLinkImportRequest) -> Option { + let usage_api_key = normalize_deeplink_api_key(request.usage_api_key.as_deref()?); + if usage_api_key.is_empty() { + return None; + } + + let provider_api_key = request + .api_key + .as_deref() + .map(normalize_deeplink_api_key) + .unwrap_or_default(); + + if !provider_api_key.is_empty() && usage_api_key == provider_api_key { + None + } else { + Some(usage_api_key) + } +} + +fn usage_base_url_override(request: &DeepLinkImportRequest) -> Option { + let usage_base_url = normalize_deeplink_base_url(request.usage_base_url.as_deref()?); + if usage_base_url.is_empty() { + return None; + } + + let provider_base_url = normalize_deeplink_base_url(&get_primary_endpoint(request)); + + if !provider_base_url.is_empty() && usage_base_url == provider_base_url { + None + } else { + Some(usage_base_url) + } +} + /// Build provider meta with usage script configuration fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, AppError> { // Check if any usage script fields are provided @@ -211,25 +253,13 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result has code > false let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); - // Build UsageScript - use provider's API key and endpoint as defaults - // Note: use primary endpoint only (first one if comma-separated) let usage_script = UsageScript { enabled, language: "javascript".to_string(), code, timeout: Some(10), - api_key: request - .usage_api_key - .clone() - .or_else(|| request.api_key.clone()), - base_url: request.usage_base_url.clone().or_else(|| { - let primary = get_primary_endpoint(request); - if primary.is_empty() { - None - } else { - Some(primary) - } - }), + api_key: usage_api_key_override(request), + base_url: usage_base_url_override(request), access_token: request.usage_access_token.clone(), user_id: request.usage_user_id.clone(), template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic) diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 7a04be2d2..8ea4b2267 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -260,6 +260,154 @@ fn test_build_gemini_provider_without_model() { assert!(env.get("GEMINI_MODEL").is_none()); } +#[test] +fn test_deeplink_usage_script_does_not_copy_provider_credentials() { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("Test Claude".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1/".to_string()), + api_key: Some("sk-main".to_string()), + icon: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled: Some(true), + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + }; + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let script = provider + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should be created"); + + assert!(script.enabled); + assert_eq!(script.api_key, None); + assert_eq!(script.base_url, None); +} + +#[test] +fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("Test Claude".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1/".to_string()), + api_key: Some("sk-main".to_string()), + icon: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled: Some(true), + usage_script: None, + usage_api_key: Some(" sk-main ".to_string()), + usage_base_url: Some(" https://api.example.com/v1/ ".to_string()), + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + }; + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let script = provider + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should be created"); + + assert_eq!(script.api_key, None); + assert_eq!(script.base_url, None); +} + +#[test] +fn test_deeplink_usage_script_preserves_distinct_usage_credentials() { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("claude".to_string()), + name: Some("Test Claude".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1".to_string()), + api_key: Some("sk-main".to_string()), + icon: None, + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, + apps: None, + repo: None, + directory: None, + branch: None, + content: None, + description: None, + enabled: None, + usage_enabled: Some(true), + usage_script: None, + usage_api_key: Some(" sk-usage ".to_string()), + usage_base_url: Some(" https://usage.example/api/ ".to_string()), + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, + }; + + let provider = build_provider_from_request(&AppType::Claude, &request).unwrap(); + let script = provider + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should be created"); + + assert_eq!(script.api_key.as_deref(), Some("sk-usage")); + assert_eq!( + script.base_url.as_deref(), + Some("https://usage.example/api") + ); +} + #[test] fn test_parse_and_merge_config_claude() { // Prepare Base64 encoded Claude config diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index b51ed4835..7e035ba50 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -101,9 +101,9 @@ mod tests { use crate::claude_desktop_config::PROFILE_ID; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; - use crate::provider::ProviderMeta; #[cfg(any(target_os = "macos", windows))] use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute}; + use crate::provider::{ProviderMeta, UsageScript}; use crate::proxy::types::ProxyConfig; use crate::store::AppState; use serde_json::json; @@ -226,6 +226,68 @@ mod tests { result } + fn codex_settings(base_url: &str, api_key: &str) -> Value { + json!({ + "auth": { + "OPENAI_API_KEY": api_key + }, + "config": format!( + "model_provider = \"custom\"\n\ + [model_providers.custom]\n\ + name = \"custom\"\n\ + base_url = \"{base_url}\"\n\ + wire_api = \"chat\"\n" + ) + }) + } + + fn usage_script_with_credentials( + api_key: Option<&str>, + base_url: Option<&str>, + template_type: Option<&str>, + ) -> UsageScript { + UsageScript { + enabled: true, + language: "javascript".to_string(), + code: "return { remaining: 1, unit: 'USD' };".to_string(), + timeout: Some(10), + api_key: api_key.map(str::to_string), + base_url: base_url.map(str::to_string), + access_token: None, + user_id: None, + template_type: template_type.map(str::to_string), + auto_query_interval: None, + coding_plan_provider: None, + access_key_id: Some("ak-test".to_string()), + secret_access_key: Some("sk-test".to_string()), + } + } + + fn codex_provider_with_usage( + id: &str, + base_url: &str, + api_key: &str, + usage_api_key: Option<&str>, + usage_base_url: Option<&str>, + template_type: Option<&str>, + ) -> Provider { + let mut provider = Provider::with_id( + id.to_string(), + format!("Provider {id}"), + codex_settings(base_url, api_key), + None, + ); + provider.meta = Some(ProviderMeta { + usage_script: Some(usage_script_with_credentials( + usage_api_key, + usage_base_url, + template_type, + )), + ..Default::default() + }); + provider + } + fn openclaw_provider(id: &str) -> Provider { Provider { id: id.to_string(), @@ -326,6 +388,255 @@ mod tests { }) } + #[test] + #[serial] + fn add_clears_usage_credentials_that_match_provider_config() { + with_test_home(|state, _| { + let provider = codex_provider_with_usage( + "codex-a", + "https://api.a.example/v1/", + "sk-a", + Some(" sk-a "), + Some(" https://api.a.example/v1/ "), + None, + ); + + ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); + + let saved = state + .db + .get_provider_by_id("codex-a", AppType::Codex.as_str()) + .expect("query saved provider") + .expect("saved provider should exist"); + let script = saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script.api_key, None); + assert_eq!(script.base_url, None); + }); + } + + #[test] + #[serial] + fn update_preserves_usage_credentials_that_only_match_previous_config() { + with_test_home(|state, _| { + let provider = codex_provider_with_usage( + "codex-usage-old", + "https://api.a.example/v1/", + "sk-a", + Some("sk-a"), + Some("https://api.a.example/v1/"), + None, + ); + state + .db + .save_provider(AppType::Codex.as_str(), &provider) + .expect("seed provider with explicit usage credentials"); + + let mut updated = provider.clone(); + updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); + + ProviderService::update(state, AppType::Codex, None, updated) + .expect("update provider main credentials"); + + let saved = state + .db + .get_provider_by_id("codex-usage-old", AppType::Codex.as_str()) + .expect("query updated provider") + .expect("updated provider should exist"); + let script = saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script.api_key.as_deref(), Some("sk-a")); + assert_eq!( + script.base_url.as_deref(), + Some("https://api.a.example/v1/") + ); + assert_eq!( + saved.resolve_usage_credentials(&AppType::Codex), + ("https://api.b.example/v1".to_string(), "sk-b".to_string()) + ); + }); + } + + #[test] + #[serial] + fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() { + with_test_home(|state, _| { + let copied_provider = codex_provider_with_usage( + "codex-copy", + "https://api.a.example/v1/", + "sk-a", + Some("sk-a"), + Some("https://api.a.example/v1/"), + None, + ); + + ProviderService::add(state, AppType::Codex, copied_provider, false) + .expect("add copied provider"); + + let saved_after_add = state + .db + .get_provider_by_id("codex-copy", AppType::Codex.as_str()) + .expect("query copied provider") + .expect("copied provider should exist"); + let script_after_add = saved_after_add + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + assert_eq!(script_after_add.api_key, None); + assert_eq!(script_after_add.base_url, None); + + let mut edited_provider = saved_after_add.clone(); + edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); + + ProviderService::update(state, AppType::Codex, None, edited_provider) + .expect("edit copied provider credentials"); + + let saved_after_update = state + .db + .get_provider_by_id("codex-copy", AppType::Codex.as_str()) + .expect("query edited provider") + .expect("edited provider should exist"); + let script_after_update = saved_after_update + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script_after_update.api_key, None); + assert_eq!(script_after_update.base_url, None); + assert_eq!( + saved_after_update.resolve_usage_credentials(&AppType::Codex), + ("https://api.b.example/v1".to_string(), "sk-b".to_string()) + ); + }); + } + + #[test] + #[serial] + fn update_clears_usage_credentials_that_match_current_config() { + with_test_home(|state, _| { + let provider = codex_provider_with_usage( + "codex-current", + "https://api.a.example/v1", + "sk-a", + Some("sk-usage"), + Some("https://usage.example/api"), + None, + ); + state + .db + .save_provider(AppType::Codex.as_str(), &provider) + .expect("seed provider with distinct usage credentials"); + + let mut updated = provider.clone(); + updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); + updated.meta = Some(ProviderMeta { + usage_script: Some(usage_script_with_credentials( + Some(" sk-b "), + Some(" https://api.b.example/v1/ "), + None, + )), + ..Default::default() + }); + + ProviderService::update(state, AppType::Codex, None, updated) + .expect("update provider with redundant usage credentials"); + + let saved = state + .db + .get_provider_by_id("codex-current", AppType::Codex.as_str()) + .expect("query updated provider") + .expect("updated provider should exist"); + let script = saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script.api_key, None); + assert_eq!(script.base_url, None); + }); + } + + #[test] + #[serial] + fn add_preserves_distinct_usage_credentials() { + with_test_home(|state, _| { + let provider = codex_provider_with_usage( + "codex-distinct", + "https://api.main.example/v1", + "sk-main", + Some("sk-usage"), + Some("https://usage.example/api"), + None, + ); + + ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); + + let saved = state + .db + .get_provider_by_id("codex-distinct", AppType::Codex.as_str()) + .expect("query saved provider") + .expect("saved provider should exist"); + let script = saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script.api_key.as_deref(), Some("sk-usage")); + assert_eq!( + script.base_url.as_deref(), + Some("https://usage.example/api") + ); + }); + } + + #[test] + #[serial] + fn add_does_not_clear_token_plan_credentials() { + with_test_home(|state, _| { + let provider = codex_provider_with_usage( + "codex-token-plan", + "https://api.plan.example/v1", + "sk-plan", + Some("sk-plan"), + Some("https://api.plan.example/v1"), + Some("token_plan"), + ); + + ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); + + let saved = state + .db + .get_provider_by_id("codex-token-plan", AppType::Codex.as_str()) + .expect("query saved provider") + .expect("saved provider should exist"); + let script = saved + .meta + .as_ref() + .and_then(|meta| meta.usage_script.as_ref()) + .expect("usage script should remain"); + + assert_eq!(script.api_key.as_deref(), Some("sk-plan")); + assert_eq!( + script.base_url.as_deref(), + Some("https://api.plan.example/v1") + ); + assert_eq!(script.access_key_id.as_deref(), Some("ak-test")); + assert_eq!(script.secret_access_key.as_deref(), Some("sk-test")); + }); + } + #[test] fn validate_provider_settings_rejects_missing_auth() { let provider = Provider::with_id( @@ -1196,6 +1507,72 @@ impl ProviderService { .live_config_managed = Some(managed); } + fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) { + let current_credentials = provider.resolve_usage_credentials(app_type); + + let Some(usage_script) = provider + .meta + .as_mut() + .and_then(|meta| meta.usage_script.as_mut()) + else { + return; + }; + + if usage_script.template_type.as_deref() == Some("token_plan") { + return; + } + + if usage_script.api_key.as_deref().is_some_and(|api_key| { + Self::should_clear_usage_api_key_override(api_key, ¤t_credentials) + }) { + usage_script.api_key = None; + } + + if usage_script.base_url.as_deref().is_some_and(|base_url| { + Self::should_clear_usage_base_url_override(base_url, ¤t_credentials) + }) { + usage_script.base_url = None; + } + } + + fn should_clear_usage_api_key_override( + script_api_key: &str, + current_credentials: &(String, String), + ) -> bool { + let candidate = script_api_key.trim(); + if candidate.is_empty() { + return true; + } + + let matches_provider_key = |api_key: &str| { + let api_key = api_key.trim(); + !api_key.is_empty() && api_key == candidate + }; + + matches_provider_key(¤t_credentials.1) + } + + fn should_clear_usage_base_url_override( + script_base_url: &str, + current_credentials: &(String, String), + ) -> bool { + let candidate = Self::normalize_usage_base_url_for_compare(script_base_url); + if candidate.is_empty() { + return true; + } + + let matches_provider_base_url = |base_url: &str| { + let base_url = Self::normalize_usage_base_url_for_compare(base_url); + !base_url.is_empty() && base_url == candidate + }; + + matches_provider_base_url(¤t_credentials.0) + } + + fn normalize_usage_base_url_for_compare(base_url: &str) -> String { + base_url.trim().trim_end_matches('/').to_string() + } + /// List all providers for an app type pub fn list( state: &AppState, @@ -1232,6 +1609,7 @@ impl ProviderService { Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; + Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if app_type.is_additive_mode() { Self::set_provider_live_config_managed(&mut provider, add_to_live); } @@ -1286,6 +1664,7 @@ impl ProviderService { Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; + Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if provider_id_changed { if !app_type.is_additive_mode() { diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 139154e66..2f03eb14d 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -17,6 +17,7 @@ import { useDeleteProviderMutation, useSwitchProviderMutation, } from "@/lib/query"; +import { usageKeys } from "@/lib/query/usage"; import { extractErrorMessage } from "@/utils/errorUtils"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { @@ -309,7 +310,7 @@ export function useProviderActions( // 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存 // 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存 await queryClient.invalidateQueries({ - queryKey: ["usage", provider.id, activeApp], + queryKey: usageKeys.script(provider.id, activeApp), }); await queryClient.invalidateQueries({ queryKey: ["subscription", "quota", activeApp], diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index b23797a7f..8ccd5f7fc 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -9,6 +9,7 @@ import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; import { invalidateHermesProviderCaches } from "@/hooks/useHermes"; +import { usageKeys } from "@/lib/query/usage"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -141,8 +142,16 @@ export const useUpdateProviderMutation = (appId: AppId) => { await providersApi.update(provider, appId, originalId); return provider; }, - onSuccess: async () => { + onSuccess: async (provider, variables) => { await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); + await queryClient.invalidateQueries({ + queryKey: usageKeys.script(provider.id, appId), + }); + if (variables.originalId && variables.originalId !== provider.id) { + await queryClient.invalidateQueries({ + queryKey: usageKeys.script(variables.originalId, appId), + }); + } if (appId === "openclaw") { await queryClient.invalidateQueries({ queryKey: openclawKeys.health, diff --git a/tests/hooks/useUpdateProviderMutation.test.tsx b/tests/hooks/useUpdateProviderMutation.test.tsx new file mode 100644 index 000000000..bff2d3f4a --- /dev/null +++ b/tests/hooks/useUpdateProviderMutation.test.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUpdateProviderMutation } from "@/lib/query/mutations"; +import { usageKeys } from "@/lib/query/usage"; +import type { Provider } from "@/types"; + +const apiMocks = vi.hoisted(() => ({ + update: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + providersApi: { + update: (...args: unknown[]) => apiMocks.update(...args), + }, + sessionsApi: {}, + settingsApi: {}, +})); + +vi.mock("@/hooks/useHermes", () => ({ + invalidateHermesProviderCaches: vi.fn(), +})); + +vi.mock("@/hooks/useOpenClaw", () => ({ + openclawKeys: { + health: ["openclaw", "health"], + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? _key, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { wrapper, invalidateSpy }; +} + +function createProvider(overrides: Partial = {}): Provider { + return { + id: "provider-1", + name: "Test Provider", + settingsConfig: {}, + ...overrides, + }; +} + +beforeEach(() => { + apiMocks.update.mockReset().mockResolvedValue(true); +}); + +describe("useUpdateProviderMutation", () => { + it("invalidates the updated provider usage query", async () => { + const { wrapper, invalidateSpy } = createWrapper(); + const provider = createProvider({ id: "provider-b" }); + const { result } = renderHook(() => useUpdateProviderMutation("codex"), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync({ provider }); + }); + + expect(apiMocks.update).toHaveBeenCalledWith(provider, "codex", undefined); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["providers", "codex"], + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: usageKeys.script("provider-b", "codex"), + }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: usageKeys.all, + }); + }); + + it("also invalidates the previous usage query when provider id changes", async () => { + const { wrapper, invalidateSpy } = createWrapper(); + const provider = createProvider({ id: "provider-new" }); + const { result } = renderHook(() => useUpdateProviderMutation("openclaw"), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync({ + provider, + originalId: "provider-old", + }); + }); + + expect(apiMocks.update).toHaveBeenCalledWith( + provider, + "openclaw", + "provider-old", + ); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: usageKeys.script("provider-new", "openclaw"), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: usageKeys.script("provider-old", "openclaw"), + }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: usageKeys.all, + }); + }); +});