158 lines
8.9 KiB
TypeScript
158 lines
8.9 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
|
import * as service from '@/services/providerService'
|
|
import ProviderForm from './ProviderForm.vue'
|
|
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
|
|
|
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
|
|
const presets: ProviderPreset[] = [
|
|
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
|
|
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
|
|
]
|
|
const existing: ProviderConfig = { provider_id: 'p1', provider_type: 'openai_compatible', name: 'DeepSeek', base_url: presets[0].base_url, default_model: 'old-model', enabled: true, credential_id: 'old-shared-key', has_credential: true, capabilities: {} }
|
|
const wrappers: ReturnType<typeof mount>[] = []
|
|
async function render(provider?: ProviderConfig) {
|
|
const wrapper = mount(ProviderForm, { props: { provider, models: [{ model_id: 'old-model', name: 'Old', capabilities: {} }] } })
|
|
wrappers.push(wrapper)
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
vi.mocked(service.listProviderPresets).mockResolvedValue(presets)
|
|
vi.mocked(service.getCredentialStatus).mockResolvedValue(true)
|
|
vi.mocked(service.putCredential).mockResolvedValue()
|
|
vi.mocked(service.createProvider).mockResolvedValue(existing)
|
|
vi.mocked(service.updateProvider).mockResolvedValue(existing)
|
|
})
|
|
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
|
|
|
describe('ProviderForm', () => {
|
|
it('filters compact preset chips and resolves bundled logos', async () => {
|
|
const wrapper = await render()
|
|
await wrapper.get('#provider-search').setValue('通义')
|
|
expect(wrapper.find('[data-preset="deepseek"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-preset="qwen"]').exists()).toBe(true)
|
|
expect(wrapper.get('[data-preset="qwen"] img').attributes('src')).not.toMatch(/^https?:/)
|
|
})
|
|
|
|
it('clears the secret, old model and credential on preset and custom selection', async () => {
|
|
const wrapper = await render(existing)
|
|
await wrapper.get('input[type="password"]').setValue('draft-secret')
|
|
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
expect((wrapper.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
|
|
expect(wrapper.findAll('datalist option')).toHaveLength(0)
|
|
await wrapper.get('form').trigger('submit')
|
|
expect(service.updateProvider).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('请输入 API Key')
|
|
await wrapper.get('input[type="password"]').setValue('new-secret')
|
|
wrapper.getComponent(ProviderPresetSelector).vm.$emit('update:modelValue', '')
|
|
await flushPromises()
|
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
})
|
|
|
|
it('allocates different credential IDs for two new providers using the same preset', async () => {
|
|
for (let i = 0; i < 2; i++) {
|
|
const wrapper = await render()
|
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
|
await wrapper.get('input[type="password"]').setValue(`test-key-${i}`)
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
}
|
|
const ids = vi.mocked(service.putCredential).mock.calls.map(call => call[0])
|
|
expect(ids).toHaveLength(2)
|
|
expect(new Set(ids).size).toBe(2)
|
|
ids.forEach(id => expect(id).toMatch(/^provider-key-[0-9a-f-]{36}$/))
|
|
vi.mocked(service.createProvider).mock.calls.forEach(([data], index) => {
|
|
expect(data.credential_id).toBe(ids[index])
|
|
expect(JSON.stringify(data)).not.toContain('test-key')
|
|
expect(JSON.stringify(data)).not.toContain('shared-deepseek')
|
|
})
|
|
})
|
|
|
|
it('accepts a custom API key and clears it after a failed credential save', async () => {
|
|
const wrapper = await render()
|
|
await wrapper.get('[data-field="name"]').setValue('Custom')
|
|
await wrapper.get('[data-field="base-url"]').setValue('https://custom.example.test/v1')
|
|
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
|
vi.mocked(service.putCredential).mockRejectedValueOnce(new Error('credential store unavailable'))
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
expect(wrapper.text()).toContain('credential store unavailable')
|
|
expect(service.createProvider).not.toHaveBeenCalled()
|
|
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(service.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Custom', credential_id: expect.stringMatching(/^provider-key-/) }))
|
|
})
|
|
|
|
it('preserves its own existing key when untouched and rotates shared legacy references when replacing a key', async () => {
|
|
const untouched = await render(existing)
|
|
await untouched.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: 'old-shared-key', default_model: 'old-model' }))
|
|
const rotated = await render(existing)
|
|
await rotated.get('input[type="password"]').setValue('replacement-test-key')
|
|
await rotated.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(service.putCredential).toHaveBeenCalledWith(expect.stringMatching(/^provider-key-/), 'replacement-test-key')
|
|
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: vi.mocked(service.putCredential).mock.calls[0][0] }))
|
|
})
|
|
|
|
it('persists edited protocols and unlinks the previous credential and model', async () => {
|
|
const wrapper = await render(existing)
|
|
await wrapper.get('[data-field="protocol"]').setValue('openai_responses')
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({ provider_type: 'openai_responses', default_model: '', credential_id: null }))
|
|
})
|
|
|
|
it('does not let a late credential status reuse a key after switching presets', async () => {
|
|
let resolveStatus!: (configured: boolean) => void
|
|
vi.mocked(service.getCredentialStatus).mockReturnValue(new Promise(resolve => { resolveStatus = resolve }))
|
|
const wrapper = await render(existing)
|
|
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
|
resolveStatus(true)
|
|
await flushPromises()
|
|
await wrapper.get('form').trigger('submit')
|
|
expect(service.updateProvider).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('请输入 API Key')
|
|
})
|
|
|
|
it('clears secrets on close and unmount, and stops a pending credential save from creating a provider', async () => {
|
|
let finish!: () => void
|
|
vi.mocked(service.putCredential).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
|
const wrapper = await render()
|
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
|
await wrapper.get('input[type="password"]').setValue('pending-test-key')
|
|
await wrapper.get('form').trigger('submit')
|
|
await wrapper.get('[aria-label="关闭提供商表单"]').trigger('click')
|
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
wrapper.unmount()
|
|
finish()
|
|
await flushPromises()
|
|
expect(service.createProvider).not.toHaveBeenCalled()
|
|
const reopened = await render()
|
|
expect((reopened.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
})
|
|
|
|
it('clears a secret after provider save failure and keeps only the successfully saved reference for retry', async () => {
|
|
const wrapper = await render()
|
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
|
await wrapper.get('input[type="password"]').setValue('retry-test-key')
|
|
vi.mocked(service.createProvider).mockRejectedValueOnce(new Error('provider save failed'))
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('provider save failed')
|
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
|
await wrapper.get('form').trigger('submit')
|
|
await flushPromises()
|
|
expect(service.putCredential).toHaveBeenCalledTimes(1)
|
|
expect(service.createProvider).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|