import { describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({ updateAccountMock: vi.fn(), checkMixedChannelRiskMock: vi.fn() })) vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn(), showInfo: vi.fn() }) })) vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isSimpleMode: true }) })) vi.mock('@/api/admin', () => ({ adminAPI: { accounts: { update: updateAccountMock, checkMixedChannelRisk: checkMixedChannelRiskMock }, settings: { getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), getSettings: vi.fn().mockResolvedValue({}) }, tlsFingerprintProfiles: { list: vi.fn().mockResolvedValue([]) } } })) vi.mock('@/api/admin/accounts', () => ({ getAntigravityDefaultModelMapping: vi.fn() })) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { ...actual, useI18n: () => ({ t: (key: string) => key }) } }) import EditAccountModal from '../EditAccountModal.vue' const BaseDialogStub = defineComponent({ name: 'BaseDialog', props: { show: { type: Boolean, default: false } }, template: '
' }) const ModelWhitelistSelectorStub = defineComponent({ name: 'ModelWhitelistSelector', props: { modelValue: { type: Array, default: () => [] } }, emits: ['update:modelValue'], template: `
{{ Array.isArray(modelValue) ? modelValue.join(',') : '' }}
` }) const SelectStub = defineComponent({ name: 'SelectStub', props: { modelValue: { type: [String, Number, Boolean, null], default: '' }, options: { type: Array, default: () => [] } }, emits: ['update:modelValue'], template: ` ` }) function buildAccount() { return { id: 1, name: 'OpenAI Key', notes: '', platform: 'openai', type: 'apikey', credentials: { api_key: 'sk-test', base_url: 'https://api.openai.com', model_mapping: { 'gpt-5.2': 'gpt-5.2' } }, extra: {}, proxy_id: null, concurrency: 1, priority: 1, rate_multiplier: 1, status: 'active', group_ids: [], expires_at: null, auto_pause_on_expired: false } as any } function buildVertexAccount() { return { id: 2, name: 'Vertex SA', notes: '', platform: 'gemini', type: 'service_account', credentials: { service_account_json: '{"type":"service_account","client_email":"sa@example.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\\nMIIE\\n-----END PRIVATE KEY-----\\n"}', project_id: 'demo-project', client_email: 'sa@example.iam.gserviceaccount.com', location: 'us-central1', tier_id: 'vertex' }, extra: {}, proxy_id: null, concurrency: 1, priority: 1, rate_multiplier: 1, status: 'active', group_ids: [], expires_at: null, auto_pause_on_expired: false } as any } function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { show: true, account, proxies: [], groups: [] }, global: { stubs: { BaseDialog: BaseDialogStub, Select: SelectStub, Icon: true, ProxySelector: true, GroupSelector: true, ModelWhitelistSelector: ModelWhitelistSelectorStub } } }) } describe('EditAccountModal', () => { it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2') await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click') expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2-2025-12-11') await wrapper.setProps({ show: false }) await wrapper.setProps({ show: true }) expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({ 'gpt-5.2': 'gpt-5.2' }) }) it('preserves model mappings when editing the whitelist', async () => { const account = buildAccount() account.credentials.model_mapping = { 'gpt-5.2': 'gpt-5.2', 'gpt-latest': 'gpt-5.2' } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2') await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({ 'gpt-5.2-2025-12-11': 'gpt-5.2-2025-12-11', 'gpt-latest': 'gpt-5.2' }) }) it('submits OpenAI compact mode and compact-only model mapping', async () => { const account = buildAccount() account.extra = { openai_compact_mode: 'force_on' } account.credentials = { ...account.credentials, compact_model_mapping: { 'gpt-5.4': 'gpt-5.4-openai-compact' } } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_compact_mode).toBe('force_on') expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.compact_model_mapping).toEqual({ 'gpt-5.4': 'gpt-5.4-openai-compact' }) }) it('submits OpenAI APIKey Responses support override mode', async () => { const account = buildAccount() account.extra = { openai_responses_mode: 'force_chat_completions', openai_responses_supported: false } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('[data-testid="openai-responses-mode-select"]').setValue('force_responses') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_mode).toBe('force_responses') expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(false) }) it('clears OpenAI APIKey Responses override when set back to auto', async () => { const account = buildAccount() account.extra = { openai_responses_mode: 'force_chat_completions', openai_responses_supported: true } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('[data-testid="openai-responses-mode-select"]').setValue('auto') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_responses_mode') expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true) }) it('submits OpenAI APIKey endpoint capabilities from credentials', async () => { const account = buildAccount() account.credentials.openai_capabilities = ['chat_completions'] updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) expect(wrapper.findAll('input[type="checkbox"]').some((input) => (input.element as HTMLInputElement).checked)).toBe(true) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([ 'chat_completions' ]) }) it('keeps at least one OpenAI APIKey endpoint capability selected', async () => { const account = buildAccount() updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) const chatCheckbox = wrapper.get( '[data-testid="openai-endpoint-capability-chat_completions"]' ) const embeddingsCheckbox = wrapper.get( '[data-testid="openai-endpoint-capability-embeddings"]' ) expect(chatCheckbox.element.checked).toBe(true) expect(embeddingsCheckbox.element.checked).toBe(true) await embeddingsCheckbox.setValue(false) expect(chatCheckbox.element.checked).toBe(true) expect(embeddingsCheckbox.element.checked).toBe(false) await chatCheckbox.setValue(false) expect(chatCheckbox.element.checked).toBe(true) expect(embeddingsCheckbox.element.checked).toBe(false) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([ 'chat_completions' ]) }) it('submits account-level Codex image generation bridge override', async () => { const account = buildAccount() account.extra = { codex_image_generation_bridge: false, codex_image_generation_bridge_enabled: true } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true) expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') }) it('allows saving apikey account when backend redacted api_key but credentials_status reports it exists', async () => { // 新前端 + 新后端:响应已脱敏,credentials 里没有 api_key,credentials_status.has_api_key=true const account = buildAccount() account.credentials = { base_url: 'https://api.openai.com', model_mapping: { 'gpt-5.2': 'gpt-5.2' } } account.credentials_status = { has_api_key: true } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) // 用户未输入新 key 时,payload 不应带 api_key,由后端合并保留旧值 expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty('api_key') }) it('allows saving apikey account against legacy backend without credentials_status', async () => { // 新前端 + 旧后端:credentials_status 缺失,但 credentials.api_key 仍是明文,应允许保存 const account = buildAccount() // 显式确保没有 credentials_status expect(account.credentials_status).toBeUndefined() updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) // 旧后端响应未脱敏,原 api_key 会随 currentCredentials 一起传回去(旧行为,等价于无操作) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.api_key).toBe('sk-test') }) it('blocks apikey save when neither credentials_status nor legacy api_key indicates existence', async () => { const account = buildAccount() account.credentials = { base_url: 'https://api.openai.com' } // 既没有 credentials_status 也没有旧的 api_key updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).not.toHaveBeenCalled() }) it('allows saving Vertex SA account when backend redacted service_account_json but credentials_status reports it exists', async () => { // 新前端 + 新后端:响应已脱敏,credentials 里没有 service_account_json,credentials_status.has_service_account_json=true const account = buildVertexAccount() account.credentials = { project_id: 'demo-project', client_email: 'sa@example.iam.gserviceaccount.com', location: 'us-central1', tier_id: 'vertex' } account.credentials_status = { has_service_account_json: true } updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.project_id).toBe('demo-project') }) it('allows saving Vertex SA account against legacy backend without credentials_status', async () => { // 新前端 + 旧后端:credentials_status 缺失,但 credentials.service_account_json 仍是明文,应允许保存 const account = buildVertexAccount() expect(account.credentials_status).toBeUndefined() expect(account.credentials.service_account_json).toBeTruthy() updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) updateAccountMock.mockResolvedValue(account) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) }) it('blocks Vertex SA save when neither credentials_status nor legacy json indicates existence', async () => { const account = buildVertexAccount() account.credentials = { project_id: 'demo-project', client_email: 'sa@example.iam.gserviceaccount.com', location: 'us-central1', tier_id: 'vertex' } // 既没有 credentials_status 也没有旧的 service_account_json updateAccountMock.mockReset() checkMixedChannelRiskMock.mockReset() checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) const wrapper = mountModal(account) await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).not.toHaveBeenCalled() }) })