import { beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent, h } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; import enCommon from "@/i18n/locales/en/common"; import enSettings from "@/i18n/locales/en/admin/settings"; import zhCommon from "@/i18n/locales/zh/common"; import zhSettings from "@/i18n/locales/zh/admin/settings"; import SettingsView from "../SettingsView.vue"; const { getSettings, updateSettings, getWebSearchEmulationConfig, updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, getPanelRateLimitSettings, updatePanelRateLimitSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, getUpstreamBillingProbeSettings, updateUpstreamBillingProbeSettings, getOllamaCloudUsageSettings, updateOllamaCloudUsageSettings, getGroups, listProxies, getProviders, updateProvider, createProvider, deleteProvider, fetchPublicSettings, adminSettingsFetch, showError, showSuccess, } = vi.hoisted(() => ({ getSettings: vi.fn(), updateSettings: vi.fn(), getWebSearchEmulationConfig: vi.fn(), updateWebSearchEmulationConfig: vi.fn(), getAdminApiKey: vi.fn(), getOverloadCooldownSettings: vi.fn(), getRateLimit429CooldownSettings: vi.fn(), updateRateLimit429CooldownSettings: vi.fn(), getPanelRateLimitSettings: vi.fn().mockResolvedValue({ enabled: true, user_rpm: 240, heavy_rpm: 60, exempt_admin: true, public_ip_rpm: 300, }), updatePanelRateLimitSettings: vi.fn().mockImplementation(async (payload) => payload), getStreamTimeoutSettings: vi.fn(), getRectifierSettings: vi.fn(), getBetaPolicySettings: vi.fn(), getUpstreamBillingProbeSettings: vi.fn().mockResolvedValue({ enabled: true, interval_minutes: 30, }), updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload), getOllamaCloudUsageSettings: vi.fn().mockResolvedValue({ enabled: false, interval_minutes: 60, debounce_minutes: 1, }), updateOllamaCloudUsageSettings: vi.fn().mockImplementation(async (payload) => payload), getGroups: vi.fn(), listProxies: vi.fn(), getProviders: vi.fn(), updateProvider: vi.fn(), createProvider: vi.fn(), deleteProvider: vi.fn(), fetchPublicSettings: vi.fn(), adminSettingsFetch: vi.fn(), showError: vi.fn(), showSuccess: vi.fn(), })); const localeRef = vi.hoisted(() => ({ value: "zh-CN" })); vi.mock("@/api", () => ({ adminAPI: { settings: { getSettings, updateSettings, getWebSearchEmulationConfig, updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, getPanelRateLimitSettings, updatePanelRateLimitSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, }, accounts: { getUpstreamBillingProbeSettings, updateUpstreamBillingProbeSettings, getOllamaCloudUsageSettings, updateOllamaCloudUsageSettings, }, groups: { getAll: getGroups, }, proxies: { list: listProxies, }, payment: { getProviders, updateProvider, createProvider, deleteProvider, }, }, })); vi.mock("@/stores", () => ({ useAppStore: () => ({ showError, showSuccess, showWarning: vi.fn(), showInfo: vi.fn(), fetchPublicSettings, }), })); vi.mock("@/stores/adminSettings", () => ({ useAdminSettingsStore: () => ({ fetch: adminSettingsFetch, }), })); vi.mock("@/composables/useClipboard", () => ({ useClipboard: () => ({ copyToClipboard: vi.fn(), }), })); vi.mock("@/utils/apiError", () => ({ extractApiErrorMessage: () => "error", })); vi.mock("vue-i18n", async () => { const actual = await vi.importActual("vue-i18n"); const translations: Record = { "admin.settings.wechatConnect.title": "微信登录", "admin.settings.wechatConnect.description": "用于微信开放平台或公众号/小程序的第三方登录配置。", "admin.settings.wechatConnect.enabledLabel": "启用微信登录", "admin.settings.wechatConnect.enabledHint": "开启后可使用微信第三方登录回调与授权配置。", "admin.settings.wechatConnect.appIdLabel": "AppID", "admin.settings.wechatConnect.appIdPlaceholder": "微信开放平台 AppID", "admin.settings.wechatConnect.appSecretLabel": "AppSecret", "admin.settings.wechatConnect.appSecretConfiguredPlaceholder": "密钥已配置,留空以保留当前值。", "admin.settings.wechatConnect.appSecretPlaceholder": "微信开放平台 AppSecret", "admin.settings.wechatConnect.appSecretConfiguredHint": "密钥已配置,留空以保留当前值。", "admin.settings.wechatConnect.appSecretHint": "填写后会覆盖当前微信密钥。", "admin.settings.wechatConnect.modeLabel": "模式", "admin.settings.wechatConnect.openModeLabel": "非微信环境使用开放平台", "admin.settings.wechatConnect.openModeHint": "浏览器不在微信内时,自动走开放平台扫码授权。", "admin.settings.wechatConnect.mpModeLabel": "微信环境使用公众号", "admin.settings.wechatConnect.mpModeHint": "浏览器在微信内时,自动走公众号授权。", "admin.settings.wechatConnect.redirectUrlLabel": "回调地址", "admin.settings.wechatConnect.redirectUrlPlaceholder": "https://your-site.com/api/v1/auth/oauth/wechat/callback", "admin.settings.wechatConnect.generateAndCopy": "使用当前站点生成并复制", "admin.settings.wechatConnect.redirectUrlSetAndCopied": "已使用当前站点生成回调地址并复制到剪贴板", "admin.settings.wechatConnect.frontendRedirectUrlLabel": "前端回调地址", "admin.settings.wechatConnect.frontendRedirectUrlPlaceholder": "/auth/wechat/callback", "admin.settings.wechatConnect.frontendRedirectUrlHint": "通常用于前端路由回调地址,需与后端配置保持一致。", "admin.settings.authSourceDefaults.title": "认证来源默认值", "admin.settings.authSourceDefaults.description": "按注册来源配置新用户默认余额、并发、订阅与授权策略。", "admin.settings.authSourceDefaults.requireEmailLabel": "第三方注册强制补充邮箱", "admin.settings.authSourceDefaults.requireEmailHint": "启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。", "admin.settings.authSourceDefaults.enabledHint": "以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。", "admin.settings.authSourceDefaults.sources.email.title": "邮箱注册", "admin.settings.authSourceDefaults.sources.email.description": "适用于邮箱密码注册的新用户默认配额。", "admin.settings.authSourceDefaults.sources.linuxdo.title": "Linux DO 登录", "admin.settings.authSourceDefaults.sources.linuxdo.description": "适用于 Linux DO 第三方注册的新用户默认配额。", "admin.settings.authSourceDefaults.sources.oidc.title": "OIDC 登录", "admin.settings.authSourceDefaults.sources.oidc.description": "适用于 OIDC 第三方注册的新用户默认配额。", "admin.settings.authSourceDefaults.sources.wechat.title": "微信登录", "admin.settings.authSourceDefaults.sources.wechat.description": "适用于微信第三方注册的新用户默认配额。", "admin.settings.authSourceDefaults.grantOnFirstBindLabel": "首次绑定时授权", "admin.settings.authSourceDefaults.grantOnFirstBindHint": "已有账号首次绑定该来源时发放默认权益。", "admin.settings.authSourceDefaults.defaultSubscriptionsLabel": "默认订阅", "admin.settings.authSourceDefaults.defaultSubscriptionsHint": "仅对当前认证来源生效,未配置时不追加来源专属订阅。", "admin.settings.authSourceDefaults.noSourceSubscriptions": "当前来源未配置专属默认订阅。", "admin.settings.paymentVisibleMethods.methodLabel": "{title} 可见方式", "admin.settings.paymentVisibleMethods.methodHint": "控制前台结算页是否展示该方式,以及展示时使用的来源键。", "admin.settings.paymentVisibleMethods.sourceLabel": "支付来源", "admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。", "admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。", "admin.settings.payment.configGuide": "查看支付配置说明", "admin.settings.payment.findProvider": "查看支持的支付方式", "admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略", "admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。", "admin.settings.openaiExperimentalScheduler.lowRatePriorityTitle": "低倍率优先", "admin.settings.openaiExperimentalScheduler.lowRatePriorityDescription": "开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。", "admin.settings.openaiExperimentalScheduler.oauthRateTitle": "OAuth 调度参考倍率", "admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", "admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", "admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权", "admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。", "admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先", "admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription": "开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。", "admin.settings.openaiExperimentalScheduler.weightsTitle": "调度权值覆盖", "admin.settings.openaiExperimentalScheduler.weightsDescription": "留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。", "admin.settings.openaiExperimentalScheduler.defaultPlaceholder": "配置/默认:{value}", "admin.settings.openaiExperimentalScheduler.topKLabel": "TopK", "admin.settings.openaiExperimentalScheduler.priorityWeight": "优先级", "admin.settings.openaiExperimentalScheduler.loadWeight": "负载", "admin.settings.openaiExperimentalScheduler.queueWeight": "排队", "admin.settings.openaiExperimentalScheduler.errorRateWeight": "错误率", "admin.settings.openaiExperimentalScheduler.ttftWeight": "首包延迟", "admin.settings.openaiExperimentalScheduler.resetWeight": "重置窗口", "admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight": "额度余量", "admin.settings.openaiExperimentalScheduler.upstreamCostWeight": "计费倍率", "admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性", "admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性", "admin.settings.upstreamBillingProbe.title": "上游倍率自动探测", "admin.settings.upstreamBillingProbe.description": "定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。", "admin.settings.upstreamBillingProbe.enabled": "启用全局自动探测", "admin.settings.upstreamBillingProbe.enabledHint": "开启后,仅对账号自身已启用自动检测的账号执行定时探测。", "admin.settings.upstreamBillingProbe.intervalMinutes": "探测周期(分钟)", "admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。", "admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存", "admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败", "admin.settings.openaiFastPolicy.summaryTargetModels": "目标模型", "admin.settings.openaiFastPolicy.summaryAllModels": "全部模型", "admin.settings.openaiFastPolicy.summaryOtherModels": "其他模型", "admin.settings.openaiFastPolicy.summaryAction.filter": "过滤", "admin.settings.openaiFastPolicy.summaryAction.pass": "透传", "admin.settings.security.passkeyDeploymentHint": "请由服务器运维在部署配置中将 webauthn.enabled 设为 true,填写 webauthn.rp_id(仅域名)与 webauthn.rp_origins(完整 HTTPS 来源),然后重启服务。", "admin.settings.site.uploadImage": "上传图片", "admin.settings.site.remove": "移除", "admin.settings.platformQuota.platform": "平台", "admin.settings.platformQuota.daily": "日限额 (USD)", "admin.settings.platformQuota.weekly": "周限额 (USD)", "admin.settings.platformQuota.monthly": "月限额 (USD, 30天滚动)", "admin.settings.platformQuota.placeholder": "不限", "admin.settings.defaults.defaultPlatformQuotas": "默认平台限额(注册时分配)", "admin.settings.defaults.defaultPlatformQuotasHint": "新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。", "admin.settings.defaults.platformQuotaNotice": "月限额为 30 天滚动窗口,非自然月", "admin.settings.authSourceDefaults.platformQuotasOverride": "平台限额覆盖", "admin.settings.authSourceDefaults.platformQuotasOverrideHint": "留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。", }; return { ...actual, useI18n: () => ({ t: (key: string, params?: Record) => (translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`), locale: localeRef, }), }; }); const AppLayoutStub = { template: "
" }; const ToggleStub = defineComponent({ props: { modelValue: { type: Boolean, default: false, }, }, emits: ["update:modelValue"], inheritAttrs: false, setup(props, { attrs, emit }) { return () => h("input", { ...attrs, class: "toggle-stub", type: "checkbox", checked: props.modelValue, onChange: (event: Event) => { emit("update:modelValue", (event.target as HTMLInputElement).checked); }, }); }, }); const SelectStub = defineComponent({ props: { modelValue: { type: [String, Number, Boolean, null], default: "", }, options: { type: Array, default: () => [], }, placeholder: { type: String, default: "", }, }, emits: ["update:modelValue", "change"], setup(props, { emit }) { const onChange = (event: Event) => { const target = event.target as HTMLSelectElement; emit("update:modelValue", target.value); const option = (props.options as Array>).find( (item) => String(item.value ?? "") === target.value, ) ?? null; emit("change", target.value, option); }; return () => h( "select", { class: "select-stub", value: props.modelValue ?? "", "data-placeholder": props.placeholder, onChange, }, (props.options as Array>).map((option) => h( "option", { key: `${String(option.value ?? "")}:${String(option.label ?? "")}`, value: option.value as string, }, String(option.label ?? ""), ), ), ); }, }); const ImageUploadStub = defineComponent({ props: { modelValue: { type: String, default: "", }, uploadLabel: { type: String, default: "", }, removeLabel: { type: String, default: "", }, placeholder: { type: String, default: "", }, }, setup(props) { return () => h("div", { class: "image-upload-stub", "data-model-value": props.modelValue, "data-upload-label": props.uploadLabel, "data-remove-label": props.removeLabel, "data-placeholder": props.placeholder, }); }, }); const baseSettingsResponse = { registration_enabled: true, email_verify_enabled: false, registration_email_suffix_whitelist: [], promo_code_enabled: true, invitation_code_enabled: false, password_reset_enabled: false, totp_enabled: false, totp_encryption_key_configured: false, passkey_enabled: true, passkey_configured: true, passkey_rp_id: "sub3.nebula-spaces.com", passkey_rp_origins: ["https://sub3.nebula-spaces.com"], default_balance: 0, default_concurrency: 1, default_subscriptions: [], site_name: "Sub2API", site_logo: "", site_subtitle: "", api_base_url: "", contact_info: "", doc_url: "", home_content: "", compact_home_enabled: false, hide_ccs_import_button: false, table_default_page_size: 20, table_page_size_options: [10, 20, 50, 100], backend_mode_enabled: false, custom_menu_items: [], custom_endpoints: [], frontend_url: "", smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password_configured: false, smtp_from_email: "", smtp_from_name: "", smtp_use_tls: true, turnstile_enabled: false, turnstile_site_key: "", turnstile_secret_key_configured: false, tencent_captcha_enabled: false, tencent_captcha_app_id: "", tencent_captcha_app_secret_key_configured: false, tencent_captcha_cloud_secret_id_configured: false, tencent_captcha_cloud_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, forwarded_client_ip_headers: [], linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", linuxdo_connect_client_secret_configured: false, linuxdo_connect_redirect_url: "", wechat_connect_enabled: true, wechat_connect_app_id: "wx-app-id-123", wechat_connect_app_secret_configured: true, wechat_connect_open_enabled: false, wechat_connect_mp_enabled: true, wechat_connect_mode: "mp", wechat_connect_scopes: "", wechat_connect_redirect_url: "https://admin.example.com/api/v1/auth/oauth/wechat/callback", wechat_connect_frontend_redirect_url: "/auth/wechat/callback", oidc_connect_enabled: false, oidc_connect_provider_name: "OIDC", oidc_connect_client_id: "", oidc_connect_client_secret_configured: false, oidc_connect_issuer_url: "", oidc_connect_discovery_url: "", oidc_connect_authorize_url: "", oidc_connect_token_url: "", oidc_connect_userinfo_url: "", oidc_connect_jwks_url: "", oidc_connect_scopes: "openid email profile", oidc_connect_redirect_url: "", oidc_connect_frontend_redirect_url: "/auth/oidc/callback", oidc_connect_token_auth_method: "client_secret_post", oidc_connect_use_pkce: true, oidc_connect_validate_id_token: true, oidc_connect_allowed_signing_algs: "RS256,ES256,PS256", oidc_connect_clock_skew_seconds: 120, oidc_connect_require_email_verified: false, oidc_connect_userinfo_email_path: "", oidc_connect_userinfo_id_path: "", oidc_connect_userinfo_username_path: "", enable_model_fallback: false, fallback_model_anthropic: "", fallback_model_openai: "", fallback_model_gemini: "", fallback_model_antigravity: "", grok_default_text_model: "grok-4.5", grok_cross_client_model_map_enabled: false, enable_identity_patch: false, identity_patch_prompt: "", ops_monitoring_enabled: false, ops_realtime_monitoring_enabled: false, ops_query_mode_default: "auto", ops_metrics_interval_seconds: 60, min_claude_code_version: "", max_claude_code_version: "", allow_ungrouped_key_scheduling: false, enable_fingerprint_unification: true, enable_metadata_passthrough: false, enable_cch_signing: false, enable_claude_oauth_system_prompt_injection: true, claude_oauth_system_prompt: "", claude_oauth_system_prompt_blocks: "", enable_anthropic_cache_ttl_1h_injection: false, rewrite_message_cache_control: false, enable_client_dateline_normalization: true, antigravity_user_agent_version: "", openai_codex_user_agent: "", payment_enabled: true, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_order_timeout_minutes: 30, payment_max_pending_orders: 3, payment_enabled_types: [], payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_subscription_usd_to_cny_rate: 0, payment_recharge_fee_rate: 0, payment_load_balance_strategy: "round-robin", payment_product_name_prefix: "", payment_product_name_suffix: "", payment_help_image_url: "", payment_help_text: "", payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: "day", payment_cancel_rate_limit_window_mode: "rolling", payment_visible_method_alipay_source: "alipay_direct", payment_visible_method_wxpay_source: "invalid-source", payment_visible_method_alipay_enabled: true, payment_visible_method_wxpay_enabled: true, openai_low_upstream_rate_priority_enabled: false, openai_oauth_scheduling_rate_multiplier: 1, openai_advanced_scheduler_enabled: false, openai_advanced_scheduler_sticky_weighted_enabled: false, openai_advanced_scheduler_subscription_priority_enabled: false, openai_advanced_scheduler_lb_top_k: "", openai_advanced_scheduler_weight_priority: "", openai_advanced_scheduler_weight_load: "", openai_advanced_scheduler_weight_queue: "", openai_advanced_scheduler_weight_error_rate: "", openai_advanced_scheduler_weight_ttft: "", openai_advanced_scheduler_weight_reset: "", openai_advanced_scheduler_weight_quota_headroom: "", openai_advanced_scheduler_weight_upstream_cost: "", openai_advanced_scheduler_weight_previous_response: "", openai_advanced_scheduler_weight_session_sticky: "", openai_advanced_scheduler_effective_lb_top_k: "7", openai_advanced_scheduler_effective_weight_priority: "1", openai_advanced_scheduler_effective_weight_load: "1", openai_advanced_scheduler_effective_weight_queue: "0.7", openai_advanced_scheduler_effective_weight_error_rate: "0.8", openai_advanced_scheduler_effective_weight_ttft: "0.5", openai_advanced_scheduler_effective_weight_reset: "0", openai_advanced_scheduler_effective_weight_quota_headroom: "0", openai_advanced_scheduler_effective_weight_upstream_cost: "0", openai_advanced_scheduler_effective_weight_previous_response: "5", openai_advanced_scheduler_effective_weight_session_sticky: "3", balance_low_notify_enabled: false, balance_low_notify_threshold: 0, balance_low_notify_recharge_url: "", subscription_expiry_notify_enabled: true, account_quota_notify_enabled: false, account_quota_notify_emails: [], // 平台限额嵌套字段(新后端契约) default_platform_quotas: { anthropic: { daily: null, weekly: null, monthly: null }, openai: { daily: null, weekly: 12.5, monthly: null }, gemini: { daily: null, weekly: null, monthly: 200 }, antigravity: { daily: null, weekly: null, monthly: null }, }, }; function mountView() { return mount(SettingsView, { global: { stubs: { AppLayout: AppLayoutStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, ConfirmDialog: true, PaymentProviderList: true, PaymentProviderDialog: true, GroupBadge: true, GroupOptionItem: true, ProxySelector: true, ImageUpload: ImageUploadStub, BackupSettings: true, }, }, }); } async function openPaymentTab(wrapper: ReturnType) { const paymentTabButton = wrapper .findAll("button") .find((node) => node.text().includes("admin.settings.tabs.payment")); expect(paymentTabButton).toBeDefined(); await paymentTabButton?.trigger("click"); await flushPromises(); } async function openSecurityTab(wrapper: ReturnType) { const securityTabButton = wrapper .findAll("button") .find((node) => node.text().includes("admin.settings.tabs.security")); expect(securityTabButton).toBeDefined(); await securityTabButton?.trigger("click"); await flushPromises(); } async function openGatewayTab(wrapper: ReturnType) { const gatewayTabButton = wrapper .findAll("button") .find((node) => node.text().includes("admin.settings.tabs.gateway")); expect(gatewayTabButton).toBeDefined(); await gatewayTabButton?.trigger("click"); await flushPromises(); } async function openUsersTab(wrapper: ReturnType) { const usersTabButton = wrapper .findAll("button") .find((node) => node.text().includes("admin.settings.tabs.users")); expect(usersTabButton).toBeDefined(); await usersTabButton?.trigger("click"); await flushPromises(); } describe("admin SettingsView email domain quota copy", () => { it("documents the email domain quota and empty-whitelist behavior in both locales", () => { expect(zhCommon.auth.emailDomainRegistrationLimit).toContain("主流邮箱"); expect(zhCommon.auth.emailDomainRegistrationLimit).toContain("联系客服"); expect(enCommon.auth.emailDomainRegistrationLimit).toContain("mainstream email"); expect(enCommon.auth.emailDomainRegistrationLimit).toContain("contact support"); // 白名单 hint 描述严格默认语义;额度语义移入独立开关的 hint。 const zhWhitelistHint = zhSettings.settings.registration.emailSuffixWhitelistHint; const enWhitelistHint = enSettings.settings.registration.emailSuffixWhitelistHint; expect(zhWhitelistHint).toContain("留空则不限制"); expect(enWhitelistHint).toContain("leave empty for no restriction"); const zhQuotaHint = zhSettings.settings.registration.emailDomainQuotaHint; const enQuotaHint = enSettings.settings.registration.emailDomainQuotaHint; expect(zhQuotaHint).toContain("其他可注册主域名各限注册一个账户"); expect(zhQuotaHint).toContain("关闭时非白名单域名直接拒绝"); expect(enQuotaHint).toContain("one account"); expect(enQuotaHint).toContain("When disabled"); }); }); describe("admin SettingsView payment visible method controls", () => { beforeEach(() => { getSettings.mockReset(); updateSettings.mockReset(); getWebSearchEmulationConfig.mockReset(); updateWebSearchEmulationConfig.mockReset(); getAdminApiKey.mockReset(); getOverloadCooldownSettings.mockReset(); getRateLimit429CooldownSettings.mockReset(); updateRateLimit429CooldownSettings.mockReset(); getStreamTimeoutSettings.mockReset(); getRectifierSettings.mockReset(); getBetaPolicySettings.mockReset(); getUpstreamBillingProbeSettings.mockReset(); updateUpstreamBillingProbeSettings.mockReset(); getOllamaCloudUsageSettings.mockReset(); updateOllamaCloudUsageSettings.mockReset(); getGroups.mockReset(); listProxies.mockReset(); getProviders.mockReset(); updateProvider.mockReset(); createProvider.mockReset(); deleteProvider.mockReset(); fetchPublicSettings.mockReset(); adminSettingsFetch.mockReset(); showError.mockReset(); showSuccess.mockReset(); localeRef.value = "zh-CN"; getSettings.mockResolvedValue({ ...baseSettingsResponse }); updateSettings.mockImplementation(async (payload) => ({ ...baseSettingsResponse, ...payload, })); getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [], }); updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [], }); getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "", }); getOverloadCooldownSettings.mockResolvedValue({ enabled: true, cooldown_minutes: 10, }); getRateLimit429CooldownSettings.mockResolvedValue({ enabled: true, cooldown_seconds: 5, }); updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload); getStreamTimeoutSettings.mockResolvedValue({ enabled: true, action: "temp_unsched", temp_unsched_minutes: 5, threshold_count: 3, threshold_window_minutes: 10, }); getRectifierSettings.mockResolvedValue({ enabled: true, thinking_signature_enabled: true, thinking_budget_enabled: true, apikey_signature_enabled: false, apikey_signature_patterns: [], }); getBetaPolicySettings.mockResolvedValue({ rules: [], }); getUpstreamBillingProbeSettings.mockResolvedValue({ enabled: true, interval_minutes: 30, }); updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload); getOllamaCloudUsageSettings.mockResolvedValue({ enabled: false, interval_minutes: 60, debounce_minutes: 1, }); updateOllamaCloudUsageSettings.mockImplementation(async (payload) => payload); getGroups.mockResolvedValue([]); listProxies.mockResolvedValue({ items: [], }); getProviders.mockResolvedValue({ data: [], }); fetchPublicSettings.mockResolvedValue(undefined); adminSettingsFetch.mockResolvedValue(undefined); }); it("submits the compact home page toggle", async () => { const wrapper = mountView(); await flushPromises(); const toggle = wrapper.get('[data-testid="compact-home-toggle"]'); expect((toggle.element as HTMLInputElement).checked).toBe(false); await toggle.setValue(true); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ compact_home_enabled: true }), ); }); it("renders panel rate limit card and saves settings", async () => { getPanelRateLimitSettings.mockClear(); updatePanelRateLimitSettings.mockClear(); getPanelRateLimitSettings.mockResolvedValue({ enabled: true, user_rpm: 240, heavy_rpm: 60, exempt_admin: true, public_ip_rpm: 300, }); updatePanelRateLimitSettings.mockImplementation(async (payload) => payload); const wrapper = mountView(); await flushPromises(); expect(getPanelRateLimitSettings).toHaveBeenCalled(); expect(wrapper.text()).toContain("admin.settings.panelRateLimit.title"); expect(wrapper.text()).toContain("admin.settings.panelRateLimit.proxySafeNote"); const userRpmInput = wrapper.find('[data-testid="panel-rate-limit-user-rpm"]'); expect(userRpmInput.exists()).toBe(true); await userRpmInput.setValue("120"); const saveButton = wrapper.find('[data-testid="panel-rate-limit-save"]'); expect(saveButton.exists()).toBe(true); await saveButton.trigger("click"); await flushPromises(); expect(updatePanelRateLimitSettings).toHaveBeenCalledWith({ enabled: true, user_rpm: 120, heavy_rpm: 60, exempt_admin: true, public_ip_rpm: 300, }); expect(showSuccess).toHaveBeenCalled(); }); it("does not render legacy visible payment method controls", async () => { const wrapper = mountView(); await flushPromises(); await openPaymentTab(wrapper); expect(wrapper.text()).not.toContain("可见方式"); expect(wrapper.text()).not.toContain("支付来源"); }); it("shows valid passkey RP configuration and persists the sign-in toggle", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const settings = wrapper.get('[data-testid="passkey-settings"]'); const toggle = settings.get('[data-testid="passkey-toggle"]'); expect(toggle.attributes("disabled")).toBeUndefined(); expect(settings.text()).toContain("sub3.nebula-spaces.com"); expect(settings.text()).toContain("https://sub3.nebula-spaces.com"); expect(settings.text()).not.toContain("webauthn.enabled"); await toggle.setValue(false); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ passkey_enabled: false }), ); }); it("人机验证切换到腾讯天御并保存四项配置", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); await masterToggle.setValue(true); // 默认选中 Turnstile expect(wrapper.text()).toContain("admin.settings.turnstile.siteKey"); await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click"); await flushPromises(); const card = wrapper .findAll(".card") .find((node) => node.text().includes("admin.settings.captcha.title")); expect(card).toBeDefined(); expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey"); expect(card!.get('a[href="https://console.cloud.tencent.com/captcha"]').exists()).toBe(true); expect(card!.get('a[href="https://console.cloud.tencent.com/cam/capi"]').exists()).toBe(true); expect( card!.get('a[href="https://cloud.tencent.com/document/product/1110/36841"]').exists(), ).toBe(true); const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox"); await inputs[0]!.setValue("123456789"); await inputs[1]!.setValue("app-secret-value"); await inputs[2]!.setValue("cloud-secret-id-value"); await inputs[3]!.setValue("cloud-secret-key-value"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ turnstile_enabled: false, tencent_captcha_enabled: true, aliyun_captcha_enabled: false, tencent_captcha_app_id: "123456789", tencent_captcha_app_secret_key: "app-secret-value", tencent_captcha_cloud_secret_id: "cloud-secret-id-value", tencent_captcha_cloud_secret_key: "cloud-secret-key-value", tencent_captcha_region: "cn", }), ); }); it("腾讯天御切换到国际站后保存站点并更新控制台入口", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); await wrapper.get('[data-testid="captcha-enabled-toggle"]').setValue(true); await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click"); await wrapper.get('[data-testid="tencent-captcha-region-intl"]').trigger("click"); const card = wrapper .findAll(".card") .find((node) => node.text().includes("admin.settings.captcha.title")); expect(card).toBeDefined(); expect(card!.get('a[href="https://console.tencentcloud.com/captcha/graphical"]').exists()).toBe( true, ); expect(card!.get('a[href="https://console.tencentcloud.com/cam/capi"]').exists()).toBe(true); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ tencent_captcha_enabled: true, tencent_captcha_region: "intl", }), ); }); it("人机验证切换到阿里云并保存配置", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); await masterToggle.setValue(true); await wrapper.get('[data-testid="captcha-provider-aliyun"]').trigger("click"); await flushPromises(); const card = wrapper .findAll(".card") .find((node) => node.text().includes("admin.settings.captcha.title")); expect(card).toBeDefined(); expect(card!.text()).toContain("admin.settings.aliyunCaptcha.region"); expect(card!.text()).not.toContain("admin.settings.turnstile.siteKey"); const inputs = card!.findAll("input").filter((input) => input.attributes("type") !== "checkbox"); await inputs[0]!.setValue("prefix-1"); await inputs[1]!.setValue("scene-1"); await inputs[2]!.setValue("ak-id"); await inputs[3]!.setValue("ak-secret-value"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ turnstile_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: true, aliyun_captcha_prefix: "prefix-1", aliyun_captcha_scene_id: "scene-1", aliyun_captcha_access_key_id: "ak-id", aliyun_captcha_access_key_secret: "ak-secret-value", aliyun_captcha_region: "cn", }), ); }); it("关闭人机验证总开关会同时关闭所有服务商", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, tencent_captcha_enabled: true, tencent_captcha_app_id: "123456789", tencent_captcha_app_secret_key_configured: true, tencent_captcha_cloud_secret_id_configured: true, tencent_captcha_cloud_secret_key_configured: true, }); const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const masterToggle = wrapper.get('[data-testid="captcha-enabled-toggle"]'); expect((masterToggle.element as HTMLInputElement).checked).toBe(true); // 加载后选中项跟随已启用的服务商 expect(wrapper.text()).toContain("admin.settings.tencentCaptcha.appId"); await masterToggle.setValue(false); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ turnstile_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false, }), ); }); it("disables passkey sign-in when the RP configuration is unavailable", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, passkey_enabled: false, passkey_configured: false, passkey_rp_id: "", passkey_rp_origins: [], }); const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const settings = wrapper.get('[data-testid="passkey-settings"]'); expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined(); const status = settings.get('[data-testid="passkey-config-status"]'); expect(status.text()).toContain( "admin.settings.security.passkeyNotConfigured", ); expect(status.text()).toContain("webauthn.enabled"); expect(status.text()).toContain("webauthn.rp_id"); expect(status.text()).toContain("webauthn.rp_origins"); expect(status.text()).toContain("然后重启服务"); }); it("loads, edits, validates, and saves forwarded client-IP headers", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, api_key_acl_trust_forwarded_ip: false, forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"], }); const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const card = wrapper .findAll(".card") .find((node) => node.text().includes("admin.settings.apiKeyAcl.title")); expect(card).toBeDefined(); const toggle = card!.get('input[type="checkbox"]'); expect((toggle.element as HTMLInputElement).checked).toBe(false); expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); await toggle.setValue(true); expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2); expect(card!.text()).toContain("Cf-Connecting-Ip"); expect(card!.text()).toContain("X-Real-Ip"); showError.mockClear(); const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]'); await input.setValue("x-client-ip"); await input.trigger("keydown", { key: "Enter" }); await input.setValue("X-CLIENT-IP"); await input.trigger("keydown", { key: "Enter" }); await input.setValue("invalid header"); await input.trigger("keydown", { key: "Enter" }); expect(showError).toHaveBeenCalledTimes(1); expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3); const realIpTag = card! .findAll('[data-testid="forwarded-client-ip-header-tag"]') .find((tag) => tag.text().includes("X-Real-Ip")); expect(realIpTag).toBeDefined(); await realIpTag!.get("button").trigger("click"); expect(card!.text()).not.toContain("X-Real-Ip"); await toggle.setValue(false); expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); await toggle.setValue(true); expect(card!.text()).toContain("X-Client-Ip"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ api_key_acl_trust_forwarded_ip: true, forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"], }), ); }); it("links payment guidance to README sections instead of removed payment docs", async () => { const wrapper = mountView(); await flushPromises(); await openPaymentTab(wrapper); const paymentLinks = wrapper .findAll("a") .filter((node) => ["查看支付配置说明", "查看支持的支付方式"].includes(node.text()), ); expect(paymentLinks).toHaveLength(2); expect(paymentLinks[0]?.attributes("href")).toBe( "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md", ); expect(paymentLinks[1]?.attributes("href")).toBe( "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式", ); for (const link of paymentLinks) { expect(link.attributes("href")).toContain("docs/PAYMENT"); } }); it("does not submit legacy visible payment method settings", async () => { const wrapper = mountView(); await flushPromises(); await openPaymentTab(wrapper); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); const payload = updateSettings.mock.calls[0]?.[0]; expect(payload).not.toHaveProperty("payment_visible_method_alipay_source"); expect(payload).not.toHaveProperty("payment_visible_method_wxpay_source"); expect(payload).not.toHaveProperty("payment_visible_method_alipay_enabled"); expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled"); }); it("submits the admin recharge affiliate rebate setting", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, affiliate_enabled: true, affiliate_admin_recharge_enabled: true, }); const wrapper = mountView(); await flushPromises(); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ affiliate_admin_recharge_enabled: true, }), ); }); it("submits Anthropic cache TTL injection gateway setting", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, enable_anthropic_cache_ttl_1h_injection: true, }); const wrapper = mountView(); await flushPromises(); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ enable_anthropic_cache_ttl_1h_injection: true, }), ); }); it("submits message cache_control rewrite gateway setting", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, rewrite_message_cache_control: true, }); const wrapper = mountView(); await flushPromises(); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ rewrite_message_cache_control: true, }), ); }); it("submits Claude OAuth system prompt injection gateway settings", async () => { const blocks = `[{"type":"text","text":"custom block","cache_control":true}]`; getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, enable_claude_oauth_system_prompt_injection: false, claude_oauth_system_prompt_blocks: blocks, }); const wrapper = mountView(); await flushPromises(); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ enable_claude_oauth_system_prompt_injection: false, }), ); const payload = updateSettings.mock.calls[0][0] as { claude_oauth_system_prompt_blocks: string; }; expect(JSON.parse(payload.claude_oauth_system_prompt_blocks)).toEqual([ { enabled: true, type: "text", text: "custom block", cache_control: { type: "ephemeral", ttl: "5m", }, }, ]); }); it("submits Antigravity user agent version gateway setting", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, antigravity_user_agent_version: "1.23.2", }); const wrapper = mountView(); await flushPromises(); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ antigravity_user_agent_version: "1.23.2", }), ); }); it("updates provider enablement immediately and reloads providers", async () => { const provider = { id: 7, provider_key: "alipay", name: "Official Alipay", config: {}, supported_types: ["alipay"], enabled: false, payment_mode: "", refund_enabled: false, allow_user_refund: false, limits: "", sort_order: 0, }; getProviders.mockReset(); getProviders .mockResolvedValueOnce({ data: [provider] }) .mockResolvedValueOnce({ data: [{ ...provider, enabled: true }] }); updateProvider.mockResolvedValue({ data: { ...provider, enabled: true } }); const PaymentProviderListStub = defineComponent({ emits: ["toggleField"], setup(_, { emit }) { return () => h( "button", { class: "provider-toggle-stub", onClick: () => emit("toggleField", provider, "enabled"), }, "toggle provider", ); }, }); const wrapper = mount(SettingsView, { global: { stubs: { AppLayout: AppLayoutStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, ConfirmDialog: true, PaymentProviderList: PaymentProviderListStub, PaymentProviderDialog: true, GroupBadge: true, GroupOptionItem: true, ProxySelector: true, ImageUpload: ImageUploadStub, BackupSettings: true, }, }, }); await flushPromises(); await openPaymentTab(wrapper); await wrapper.get(".provider-toggle-stub").trigger("click"); await flushPromises(); expect(updateProvider).toHaveBeenCalledWith(7, { enabled: true }); expect(getProviders).toHaveBeenCalledTimes(2); }); it("renders advanced scheduler copy as local experimental gateway policy", async () => { const wrapper = mountView(); await flushPromises(); expect(wrapper.text()).toContain("OpenAI 实验调度策略"); expect(wrapper.text()).toContain( "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑", ); expect(wrapper.text()).not.toContain("OpenAI 高级调度器"); }); it("summarizes target and other-model actions, then switches to all models", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, openai_fast_policy_settings: { rules: [ { service_tier: "all", action: "filter", scope: "all", model_whitelist: ["gpt-5.6-sol"], fallback_action: "pass", }, ], }, }); const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); const summary = wrapper.get('[data-testid="openai-fast-policy-summary-0"]'); expect(summary.text()).toContain("目标模型"); expect(summary.text()).toContain("过滤"); expect(summary.text()).toContain("其他模型"); expect(summary.text()).toContain("透传"); await wrapper .get( '[role="group"][aria-labelledby="openai-fast-policy-models-label-0"] input[type="text"]', ) .setValue(""); expect(summary.text()).toContain("全部模型"); expect(summary.text()).toContain("过滤"); expect(summary.text()).not.toContain("其他模型"); expect(summary.text()).not.toContain("透传"); }); it("loads and saves upstream billing probe settings from the gateway tab", async () => { getUpstreamBillingProbeSettings.mockResolvedValueOnce({ enabled: false, interval_minutes: 45, }); const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); const card = wrapper.get('[data-testid="upstream-billing-probe-settings"]'); expect(card.isVisible()).toBe(true); expect(card.text()).toContain("上游倍率自动探测"); expect( (card.get('[data-testid="upstream-billing-probe-enabled"]').element as HTMLInputElement) .checked, ).toBe(false); expect(card.find('[data-testid="upstream-billing-probe-interval"]').exists()).toBe(false); await card.get('[data-testid="upstream-billing-probe-enabled"]').setValue(true); await card.get('[data-testid="upstream-billing-probe-interval"]').setValue(60); await card.get('[data-testid="upstream-billing-probe-save"]').trigger("click"); await flushPromises(); expect(updateUpstreamBillingProbeSettings).toHaveBeenCalledWith({ enabled: true, interval_minutes: 60, }); expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存"); }); it("loads and saves configurable Grok cross-client model mapping", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, grok_default_text_model: "grok-4.1-fast", grok_cross_client_model_map_enabled: true, }); const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); const modelInput = wrapper.get('[data-testid="grok-default-text-model"]'); const mappingToggle = wrapper.get( '[data-testid="grok-cross-client-model-map-toggle"]', ); expect((modelInput.element as HTMLInputElement).value).toBe("grok-4.1-fast"); expect((mappingToggle.element as HTMLInputElement).checked).toBe(true); await modelInput.setValue("grok-custom-text"); await mappingToggle.setValue(false); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); const payload = updateSettings.mock.calls.at(-1)?.[0] as Record; expect(payload.grok_default_text_model).toBe("grok-custom-text"); expect(payload.grok_cross_client_model_map_enabled).toBe(false); }); it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => { const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); const card = wrapper.get('[data-testid="ollama-cloud-usage-global-settings"]'); expect(card.isVisible()).toBe(true); expect( (card.get('[data-testid="ollama-cloud-usage-global-enabled"]').element as HTMLInputElement) .checked, ).toBe(false); expect(card.find('[data-testid="ollama-cloud-usage-global-interval"]').exists()).toBe(false); await card.get('[data-testid="ollama-cloud-usage-global-enabled"]').setValue(true); await card.get('[data-testid="ollama-cloud-usage-global-debounce"]').setValue(3); await card.get('[data-testid="ollama-cloud-usage-global-interval"]').setValue(90); await card.get('[data-testid="ollama-cloud-usage-global-save"]').trigger("click"); await flushPromises(); expect(updateOllamaCloudUsageSettings).toHaveBeenCalledWith({ enabled: true, interval_minutes: 90, debounce_minutes: 3, }); }); it("places and explains rate controls for both scheduling modes", async () => { const wrapper = mountView(); await flushPromises(); expect( wrapper.find('[data-testid="openai-oauth-scheduling-rate-multiplier"]').exists(), ).toBe(false); const lowRateToggle = wrapper.get('[data-testid="openai-low-rate-priority-toggle"]'); await lowRateToggle.setValue(true); const priorityModeText = wrapper.text(); expect(priorityModeText).toContain( "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", ); expect(priorityModeText.indexOf("低倍率优先")).toBeLessThan( priorityModeText.indexOf("OAuth 调度参考倍率"), ); expect(priorityModeText.indexOf("OAuth 调度参考倍率")).toBeLessThan( priorityModeText.indexOf("OpenAI 实验调度策略"), ); const oauthRateInput = wrapper.get( '[data-testid="openai-oauth-scheduling-rate-multiplier"]', ); await oauthRateInput.setValue("0.05"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ openai_low_upstream_rate_priority_enabled: true, openai_oauth_scheduling_rate_multiplier: 0.05, }), ); await wrapper .get('[data-testid="openai-advanced-scheduler-toggle"]') .setValue(true); expect( wrapper.find('[data-testid="openai-low-rate-priority-toggle"]').exists(), ).toBe(false); expect( wrapper.find('[data-testid="openai-oauth-scheduling-rate-multiplier"]').exists(), ).toBe(true); const weightedModeText = wrapper.text(); expect(weightedModeText).toContain( "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", ); expect(weightedModeText).not.toContain( "OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", ); expect(weightedModeText.indexOf("订阅优先")).toBeLessThan( weightedModeText.indexOf("OAuth 调度参考倍率"), ); expect(weightedModeText.indexOf("OAuth 调度参考倍率")).toBeLessThan( weightedModeText.indexOf("调度权值覆盖"), ); expect(weightedModeText).toContain("计费倍率"); }); it("passes translated upload and remove labels to the payment help image uploader", async () => { const wrapper = mountView(); await flushPromises(); await openPaymentTab(wrapper); const imageUploads = wrapper.findAll(".image-upload-stub"); expect(imageUploads.length).toBeGreaterThan(0); const paymentHelpImageUpload = imageUploads.find( (node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder", ); expect(paymentHelpImageUpload).toBeDefined(); expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片"); expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除"); }); it("normalizes null supported_types from API so provider card stays visible", async () => { // Backend returns null for supported_types when the list is empty // (Go nil slice → JSON null). Without normalization, ProviderCard's // isSelected() throws TypeError on null.includes(), causing the card // to vanish from the list. const providerWithNullTypes = { id: 42, provider_key: "easypay", name: "EasyPay", config: {}, supported_types: null as unknown as string[], enabled: true, payment_mode: "", refund_enabled: false, allow_user_refund: false, limits: "", sort_order: 0, }; getProviders.mockReset(); getProviders.mockResolvedValue({ data: [providerWithNullTypes] }); let receivedProviders: Array> = []; const PaymentProviderListCapture = defineComponent({ props: { providers: { type: Array, default: () => [], }, }, setup(props) { receivedProviders = props.providers as Array>; return () => h("div", { class: "provider-list-capture" }); }, }); const wrapper = mount(SettingsView, { global: { stubs: { AppLayout: AppLayoutStub, Select: SelectStub, Toggle: ToggleStub, Icon: true, ConfirmDialog: true, PaymentProviderList: PaymentProviderListCapture, PaymentProviderDialog: true, GroupBadge: true, GroupOptionItem: true, ProxySelector: true, ImageUpload: ImageUploadStub, BackupSettings: true, }, }, }); await flushPromises(); await openPaymentTab(wrapper); // The provider should still be in the list expect(receivedProviders.length).toBe(1); // supported_types should be normalized to an empty array, not null expect(Array.isArray(receivedProviders[0].supported_types)).toBe(true); expect(receivedProviders[0].supported_types).toEqual([]); }); }); describe("admin SettingsView wechat connect controls", () => { beforeEach(() => { getSettings.mockReset(); updateSettings.mockReset(); getWebSearchEmulationConfig.mockReset(); updateWebSearchEmulationConfig.mockReset(); getAdminApiKey.mockReset(); getOverloadCooldownSettings.mockReset(); getRateLimit429CooldownSettings.mockReset(); updateRateLimit429CooldownSettings.mockReset(); getStreamTimeoutSettings.mockReset(); getRectifierSettings.mockReset(); getBetaPolicySettings.mockReset(); getGroups.mockReset(); listProxies.mockReset(); getProviders.mockReset(); updateProvider.mockReset(); createProvider.mockReset(); deleteProvider.mockReset(); fetchPublicSettings.mockReset(); adminSettingsFetch.mockReset(); showError.mockReset(); showSuccess.mockReset(); getSettings.mockResolvedValue({ ...baseSettingsResponse, payment_visible_method_wxpay_source: "official_wxpay", }); updateSettings.mockImplementation(async (payload) => ({ ...baseSettingsResponse, payment_visible_method_wxpay_source: "official_wxpay", ...payload, })); getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [], }); updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [], }); getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "", }); getOverloadCooldownSettings.mockResolvedValue({ enabled: true, cooldown_minutes: 10, }); getRateLimit429CooldownSettings.mockResolvedValue({ enabled: true, cooldown_seconds: 5, }); updateRateLimit429CooldownSettings.mockImplementation(async (payload) => payload); getStreamTimeoutSettings.mockResolvedValue({ enabled: true, action: "temp_unsched", temp_unsched_minutes: 5, threshold_count: 3, threshold_window_minutes: 10, }); getRectifierSettings.mockResolvedValue({ enabled: true, thinking_signature_enabled: true, thinking_budget_enabled: true, apikey_signature_enabled: false, apikey_signature_patterns: [], }); getBetaPolicySettings.mockResolvedValue({ rules: [], }); getGroups.mockResolvedValue([]); listProxies.mockResolvedValue({ items: [], }); getProviders.mockResolvedValue({ data: [], }); fetchPublicSettings.mockResolvedValue(undefined); adminSettingsFetch.mockResolvedValue(undefined); }); it("loads and echoes WeChat Connect fields from the backend payload", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); expect( ( wrapper.get('[data-testid="wechat-connect-mp-app-id"]') .element as HTMLInputElement ).value, ).toBe("wx-app-id-123"); expect( ( wrapper.get('[data-testid="wechat-connect-open-enabled"]') .element as HTMLInputElement ).checked, ).toBe(false); expect( ( wrapper.get('[data-testid="wechat-connect-mp-enabled"]') .element as HTMLInputElement ).checked, ).toBe(true); expect(wrapper.find('[data-testid="wechat-connect-scopes"]').exists()).toBe( false, ); expect( wrapper .get('[data-testid="wechat-connect-mp-app-secret"]') .attributes("placeholder"), ).toContain("密钥已配置"); expect( ( wrapper.get('[data-testid="wechat-connect-frontend-redirect-url"]') .element as HTMLInputElement ).value, ).toBe("/auth/wechat/callback"); }); it("links GitHub OAuth Apps guide to GitHub developer settings", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, github_oauth_enabled: true, }); const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]'); expect(link.text()).toContain("OAuth Apps"); expect(link.attributes("href")).toBe("https://github.com/settings/developers"); expect(link.attributes("target")).toBe("_blank"); expect(link.attributes("rel")).toContain("noopener"); }); it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => { const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); await wrapper .get('[data-testid="wechat-connect-mp-app-id"]') .setValue("wx-app-id-updated"); await wrapper .get('[data-testid="wechat-connect-mp-app-secret"]') .setValue("new-secret"); await wrapper .get('[data-testid="wechat-connect-open-enabled"]') .setValue(true); await wrapper .get('[data-testid="wechat-connect-mp-enabled"]') .setValue(true); await wrapper .get('[data-testid="wechat-connect-redirect-url"]') .setValue("https://admin.example.com/api/v1/auth/oauth/wechat/callback"); await wrapper .get('[data-testid="wechat-connect-frontend-redirect-url"]') .setValue("/auth/wechat/callback"); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ wechat_connect_enabled: true, wechat_connect_app_id: "wx-app-id-updated", wechat_connect_open_enabled: true, wechat_connect_mp_enabled: true, wechat_connect_mp_app_id: "wx-app-id-updated", wechat_connect_mp_app_secret: "new-secret", wechat_connect_redirect_url: "https://admin.example.com/api/v1/auth/oauth/wechat/callback", wechat_connect_frontend_redirect_url: "/auth/wechat/callback", }), ); expect( ( wrapper.get('[data-testid="wechat-connect-mp-app-secret"]') .element as HTMLInputElement ).value, ).toBe(""); expect( wrapper .get('[data-testid="wechat-connect-mp-app-secret"]') .attributes("placeholder"), ).toContain("密钥已配置"); }); it("collapses auth source defaults until the source is enabled", async () => { const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); expect( ( wrapper.get('[data-testid="auth-source-email-enabled"]') .element as HTMLInputElement ).checked, ).toBe(false); expect( wrapper.find('[data-testid="auth-source-email-panel"]').exists(), ).toBe(false); expect(wrapper.text()).not.toContain("注册即授权"); await wrapper .get('[data-testid="auth-source-email-enabled"]') .setValue(true); expect( wrapper.find('[data-testid="auth-source-email-panel"]').exists(), ).toBe(true); expect(wrapper.text()).toContain("首次绑定时授权"); }); it("preserves optional OIDC compatibility flags instead of forcing them on save", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, oidc_connect_enabled: true, oidc_connect_use_pkce: false, oidc_connect_validate_id_token: false, }); const wrapper = mountView(); await flushPromises(); await openSecurityTab(wrapper); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledTimes(1); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ oidc_connect_use_pkce: false, oidc_connect_validate_id_token: false, }), ); }); }); describe("admin SettingsView platform quota matrix", () => { beforeEach(() => { getSettings.mockReset(); updateSettings.mockReset(); getWebSearchEmulationConfig.mockReset(); updateWebSearchEmulationConfig.mockReset(); getAdminApiKey.mockReset(); getOverloadCooldownSettings.mockReset(); getRateLimit429CooldownSettings.mockReset(); updateRateLimit429CooldownSettings.mockReset(); getStreamTimeoutSettings.mockReset(); getRectifierSettings.mockReset(); getBetaPolicySettings.mockReset(); getGroups.mockReset(); listProxies.mockReset(); getProviders.mockReset(); updateProvider.mockReset(); createProvider.mockReset(); deleteProvider.mockReset(); fetchPublicSettings.mockReset(); adminSettingsFetch.mockReset(); showError.mockReset(); showSuccess.mockReset(); localeRef.value = "zh-CN"; getSettings.mockResolvedValue({ ...baseSettingsResponse }); updateSettings.mockImplementation(async (payload) => ({ ...baseSettingsResponse, ...payload, })); getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] }); updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] }); getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "" }); getOverloadCooldownSettings.mockResolvedValue({}); getRateLimit429CooldownSettings.mockResolvedValue({}); updateRateLimit429CooldownSettings.mockResolvedValue({}); getStreamTimeoutSettings.mockResolvedValue({}); getRectifierSettings.mockResolvedValue({}); getBetaPolicySettings.mockResolvedValue({}); getGroups.mockResolvedValue([]); listProxies.mockResolvedValue({ items: [] }); getProviders.mockResolvedValue({ data: [] }); }); it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 5 平台行", async () => { const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); expect(getSettings).toHaveBeenCalled(); const html = wrapper.html(); // 表格行的平台字段:font-mono 渲染纯英文 platform key expect(html).toContain("anthropic"); expect(html).toContain("openai"); expect(html).toContain("gemini"); expect(html).toContain("antigravity"); }); it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 5 平台)", async () => { const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalled(); const lastCallArgs = updateSettings.mock.calls.at(-1); expect(lastCallArgs).toBeDefined(); const payload = lastCallArgs![0] as Record; // 应携带嵌套对象,而非扁平字段 expect(payload).toHaveProperty("default_platform_quotas"); const quotas = payload["default_platform_quotas"] as Record; const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok"]; for (const p of platforms) { expect(quotas).toHaveProperty(p); const pq = quotas[p] as Record; expect(pq).toHaveProperty("daily"); expect(pq).toHaveProperty("weekly"); expect(pq).toHaveProperty("monthly"); } // 不应存在旧扁平字段 expect(payload).not.toHaveProperty("default_platform_quota_anthropic_daily"); expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly"); }); it("加载后 form.default_platform_quotas 含全 5 平台,从嵌套 JSON 正确读取数值", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, default_platform_quotas: { anthropic: { daily: 5, weekly: null, monthly: null }, openai: { daily: null, weekly: 12.5, monthly: null }, // gemini / antigravity 缺失 → 应被归一化为全 null }, }); const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); const payload = updateSettings.mock.calls.at(-1)![0] as Record; const quotas = payload["default_platform_quotas"] as Record>; expect(quotas["anthropic"]?.["daily"]).toBe(5); expect(quotas["openai"]?.["weekly"]).toBe(12.5); // 缺失平台应补全为 null expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null }); expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null }); }); it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => { // 模拟后端返回带有 anthropic daily 值的配额 getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, default_platform_quotas: { anthropic: { daily: 10, weekly: null, monthly: null }, openai: { daily: null, weekly: null, monthly: null }, gemini: { daily: null, weekly: null, monthly: null }, antigravity: { daily: null, weekly: null, monthly: null }, }, }); const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); // 找到 anthropic daily 输入框并清空(模拟用户删除值) const inputs = wrapper.findAll('input[type="number"]'); const anthropicDailyInput = inputs.find((i) => { const parent = i.element.closest("tr"); return parent?.textContent?.includes("anthropic"); }); if (anthropicDailyInput) { // 设置为空字符串,模拟 v-model.number 在清空时产出 "" await anthropicDailyInput.setValue(""); } await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); const payload = updateSettings.mock.calls.at(-1)![0] as Record; const quotas = payload["default_platform_quotas"] as Record>; // 不管输入是什么,提交值应为 null(而非 "" 或 NaN) expect(quotas["anthropic"]?.["daily"]).toBe(null); }); });