import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import SettingsView from '../SettingsView.vue' const { getSettings, updateSettings, getWebSearchEmulationConfig, updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, getGroups, listProxies, getProviders, fetchPublicSettings, adminSettingsFetch, showError, showSuccess, } = vi.hoisted(() => ({ getSettings: vi.fn(), updateSettings: vi.fn(), getWebSearchEmulationConfig: vi.fn(), updateWebSearchEmulationConfig: vi.fn(), getAdminApiKey: vi.fn(), getOverloadCooldownSettings: vi.fn(), getStreamTimeoutSettings: vi.fn(), getRectifierSettings: vi.fn(), getBetaPolicySettings: vi.fn(), getGroups: vi.fn(), listProxies: vi.fn(), getProviders: vi.fn(), fetchPublicSettings: vi.fn(), adminSettingsFetch: vi.fn(), showError: vi.fn(), showSuccess: vi.fn(), })) vi.mock('@/api', () => ({ adminAPI: { settings: { getSettings, updateSettings, getWebSearchEmulationConfig, updateWebSearchEmulationConfig, getAdminApiKey, getOverloadCooldownSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, }, groups: { getAll: getGroups, }, proxies: { list: listProxies, }, payment: { getProviders, }, }, })) 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') return { ...actual, useI18n: () => ({ t: (key: string) => key, locale: ref('zh-CN'), }), } }) const AppLayoutStub = { template: '
' } const ToggleStub = defineComponent({ props: { modelValue: { type: Boolean, default: false, }, }, emits: ['update:modelValue'], setup(props, { emit }) { return () => h('input', { 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 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, default_balance: 0, default_concurrency: 1, default_subscriptions: [], site_name: 'Sub2API', site_logo: '', site_subtitle: '', api_base_url: '', contact_info: '', doc_url: '', home_content: '', 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, linuxdo_connect_enabled: false, linuxdo_connect_client_id: '', linuxdo_connect_client_secret_configured: false, linuxdo_connect_redirect_url: '', 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: '', 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, 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_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_advanced_scheduler_enabled: false, balance_low_notify_enabled: false, balance_low_notify_threshold: 0, balance_low_notify_recharge_url: '', account_quota_notify_enabled: false, account_quota_notify_emails: [], } 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: true, 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() } describe('admin SettingsView payment visible method controls', () => { beforeEach(() => { getSettings.mockReset() updateSettings.mockReset() getWebSearchEmulationConfig.mockReset() updateWebSearchEmulationConfig.mockReset() getAdminApiKey.mockReset() getOverloadCooldownSettings.mockReset() getStreamTimeoutSettings.mockReset() getRectifierSettings.mockReset() getBetaPolicySettings.mockReset() getGroups.mockReset() listProxies.mockReset() getProviders.mockReset() fetchPublicSettings.mockReset() adminSettingsFetch.mockReset() showError.mockReset() showSuccess.mockReset() 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, }) 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 canonical source options and normalizes existing values', async () => { const wrapper = mountView() await flushPromises() await openPaymentTab(wrapper) const paymentSourceSelects = wrapper .findAll('select.select-stub') .filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) expect(paymentSourceSelects).toHaveLength(2) const alipaySelect = paymentSourceSelects.find( (node) => node.attributes('data-placeholder') === 'alipay' ) const wxpaySelect = paymentSourceSelects.find( (node) => node.attributes('data-placeholder') === 'wxpay' ) expect(alipaySelect?.element.value).toBe('official_alipay') expect(alipaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ '', 'official_alipay', 'easypay_alipay', ]) expect(wxpaySelect?.element.value).toBe('') expect(wxpaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ '', 'official_wxpay', 'easypay_wxpay', ]) }) it('saves canonical source keys selected from the dropdowns', async () => { const wrapper = mountView() await flushPromises() await openPaymentTab(wrapper) const paymentSourceSelects = wrapper .findAll('select.select-stub') .filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) const alipaySelect = paymentSourceSelects.find( (node) => node.attributes('data-placeholder') === 'alipay' ) const wxpaySelect = paymentSourceSelects.find( (node) => node.attributes('data-placeholder') === 'wxpay' ) await alipaySelect?.setValue('easypay_alipay') await wxpaySelect?.setValue('official_wxpay') await wrapper.find('form').trigger('submit.prevent') await flushPromises() expect(updateSettings).toHaveBeenCalledTimes(1) expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ payment_visible_method_alipay_source: 'easypay_alipay', payment_visible_method_wxpay_source: 'official_wxpay', payment_visible_method_alipay_enabled: true, payment_visible_method_wxpay_enabled: true, }) ) }) it('blocks saving when a visible payment method is enabled without a source', async () => { const wrapper = mountView() await flushPromises() await openPaymentTab(wrapper) const paymentSourceSelects = wrapper .findAll('select.select-stub') .filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) const alipaySelect = paymentSourceSelects.find( (node) => node.attributes('data-placeholder') === 'alipay' ) await alipaySelect?.setValue('') await wrapper.find('form').trigger('submit.prevent') await flushPromises() expect(updateSettings).not.toHaveBeenCalled() expect(showError).toHaveBeenCalled() expect(String(showError.mock.calls.at(-1)?.[0] ?? '')).toContain('支付来源') }) })