mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
|
|
|
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
|
import type { Model, ProviderEndpoint } from '@/api/endpoints'
|
|
import { updateModel } from '@/api/endpoints/models'
|
|
|
|
const upstreamModelMocks = vi.hoisted(() => ({
|
|
fetchModels: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/components/ui', async () => {
|
|
const { defineComponent, h } = await import('vue')
|
|
|
|
const passthrough = (name: string, tag = 'div') => defineComponent({
|
|
name,
|
|
setup(_, { slots }) {
|
|
return () => h(tag, [slots.default?.(), slots.footer?.()])
|
|
},
|
|
})
|
|
|
|
return {
|
|
Button: defineComponent({
|
|
name: 'ButtonStub',
|
|
setup(_, { attrs, slots }) {
|
|
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
|
},
|
|
}),
|
|
Dialog: passthrough('DialogStub'),
|
|
Input: defineComponent({
|
|
name: 'InputStub',
|
|
props: { modelValue: String },
|
|
emits: ['update:modelValue'],
|
|
setup(props, { attrs, emit }) {
|
|
return () => h('input', {
|
|
...attrs,
|
|
value: props.modelValue ?? '',
|
|
onInput: (event: Event) => emit(
|
|
'update:modelValue',
|
|
(event.target as HTMLInputElement).value,
|
|
),
|
|
})
|
|
},
|
|
}),
|
|
Label: passthrough('LabelStub', 'label'),
|
|
Select: passthrough('SelectStub'),
|
|
SelectContent: passthrough('SelectContentStub'),
|
|
SelectItem: passthrough('SelectItemStub'),
|
|
SelectTrigger: passthrough('SelectTriggerStub'),
|
|
SelectValue: passthrough('SelectValueStub', 'span'),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/components/common/MultiSelect.vue', async () => {
|
|
const { defineComponent, h } = await import('vue')
|
|
return {
|
|
default: defineComponent({
|
|
name: 'MultiSelectStub',
|
|
props: {
|
|
modelValue: { type: Array, default: () => [] },
|
|
options: { type: Array, default: () => [] },
|
|
},
|
|
emits: ['update:modelValue'],
|
|
setup(props, { emit }) {
|
|
return () => h('div', (props.options as Array<{ value: string, label: string }>).map(option => h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
'data-endpoint-id': option.value,
|
|
onClick: () => emit('update:modelValue', [option.value]),
|
|
},
|
|
option.label,
|
|
)))
|
|
},
|
|
}),
|
|
}
|
|
})
|
|
|
|
vi.mock('lucide-vue-next', async () => {
|
|
const { defineComponent, h } = await import('vue')
|
|
const Icon = defineComponent({
|
|
name: 'IconStub',
|
|
setup() {
|
|
return () => h('span')
|
|
},
|
|
})
|
|
return {
|
|
Check: Icon,
|
|
ChevronDown: Icon,
|
|
Loader2: Icon,
|
|
Plus: Icon,
|
|
RefreshCw: Icon,
|
|
Search: Icon,
|
|
Tag: Icon,
|
|
Zap: Icon,
|
|
}
|
|
})
|
|
|
|
vi.mock('@/api/endpoints/models', () => ({
|
|
updateModel: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/composables/useToast', () => ({
|
|
useToast: () => ({
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
warning: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('../../composables/useUpstreamModelsCache', () => ({
|
|
useUpstreamModelsCache: () => ({
|
|
fetchModels: upstreamModelMocks.fetchModels,
|
|
}),
|
|
}))
|
|
|
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
|
|
|
afterEach(() => {
|
|
vi.mocked(updateModel).mockClear()
|
|
upstreamModelMocks.fetchModels.mockReset()
|
|
for (const { app, root } of mountedApps.splice(0)) {
|
|
app.unmount()
|
|
root.remove()
|
|
}
|
|
})
|
|
|
|
describe('ModelMappingDialog', () => {
|
|
it('initializes upstream models when lazily mounted in the open state', async () => {
|
|
upstreamModelMocks.fetchModels.mockResolvedValue({
|
|
models: [],
|
|
error: null,
|
|
warning: null,
|
|
})
|
|
const model: Model = {
|
|
provider_id: 'provider-1',
|
|
global_model_id: 'global-model-1',
|
|
is_active: true,
|
|
is_available: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
id: 'model-1',
|
|
provider_model_name: 'provider-model-1',
|
|
provider_model_mappings: [],
|
|
}
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup() {
|
|
return () => h(ModelMappingDialog, {
|
|
open: true,
|
|
providerId: 'provider-1',
|
|
models: [model],
|
|
hasAutoFetchKey: true,
|
|
})
|
|
},
|
|
}))
|
|
app.mount(root)
|
|
mountedApps.push({ app, root })
|
|
|
|
await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1))
|
|
expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1', undefined, false)
|
|
|
|
// Both manual fetch and refresh must bypass the backend cache.
|
|
upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-old' }] })
|
|
await nextTick()
|
|
const fetchButton = root.querySelector<HTMLButtonElement>('[title="从提供商获取模型"]')
|
|
expect(fetchButton).not.toBeNull()
|
|
fetchButton!.click()
|
|
await vi.waitFor(() => expect(root.textContent).toContain('gpt-old'))
|
|
expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true)
|
|
|
|
upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-6-astra' }] })
|
|
const refreshButton = root.querySelector<HTMLButtonElement>('[title="刷新上游模型"]')
|
|
expect(refreshButton).not.toBeNull()
|
|
refreshButton!.click()
|
|
await vi.waitFor(() => expect(root.textContent).toContain('gpt-6-astra'))
|
|
expect(root.textContent).not.toContain('gpt-old')
|
|
expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true)
|
|
})
|
|
|
|
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
|
|
const chatEndpoint = {
|
|
id: 'endpoint-chat',
|
|
api_format: 'openai:chat',
|
|
base_url: 'https://api.example.com/v1',
|
|
is_active: true,
|
|
} as ProviderEndpoint
|
|
const responsesEndpoint = {
|
|
id: 'endpoint-responses',
|
|
api_format: 'openai:responses',
|
|
base_url: 'https://api.example.com/v1',
|
|
is_active: true,
|
|
} as ProviderEndpoint
|
|
const model: Model = {
|
|
provider_id: 'provider-1',
|
|
global_model_id: 'global-model-1',
|
|
is_active: true,
|
|
is_available: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
id: 'model-sol',
|
|
provider_model_name: 'gpt-5.6-sol',
|
|
global_model_display_name: 'GPT-5.6 Sol',
|
|
provider_model_mappings: [],
|
|
}
|
|
const open = ref(false)
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup() {
|
|
return () => h(ModelMappingDialog, {
|
|
open: open.value,
|
|
providerId: 'provider-1',
|
|
endpoints: [chatEndpoint, responsesEndpoint],
|
|
models: [model],
|
|
preselectedModelId: model.id,
|
|
'onUpdate:open': (value: boolean) => { open.value = value },
|
|
})
|
|
},
|
|
}))
|
|
app.mount(root)
|
|
mountedApps.push({ app, root })
|
|
|
|
open.value = true
|
|
await nextTick()
|
|
await nextTick()
|
|
|
|
expect(root.textContent).toContain('所有请求')
|
|
expect(root.textContent).not.toContain('仅会话压缩')
|
|
|
|
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
|
|
await nextTick()
|
|
expect(root.textContent).not.toContain('仅会话压缩')
|
|
|
|
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-responses"]')?.click()
|
|
await nextTick()
|
|
expect(root.textContent).toContain('仅会话压缩')
|
|
})
|
|
|
|
it('returns to all requests when a compact mapping switches away from Responses', async () => {
|
|
const responsesEndpoint = {
|
|
id: 'endpoint-responses',
|
|
api_format: 'openai:responses',
|
|
base_url: 'https://api.example.com/v1',
|
|
is_active: true,
|
|
} as ProviderEndpoint
|
|
const chatEndpoint = {
|
|
id: 'endpoint-chat',
|
|
api_format: 'openai:chat',
|
|
base_url: 'https://api.example.com/v1',
|
|
is_active: true,
|
|
} as ProviderEndpoint
|
|
const model: Model = {
|
|
provider_id: 'provider-1',
|
|
global_model_id: 'global-model-1',
|
|
is_active: true,
|
|
is_available: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
id: 'model-sol',
|
|
provider_model_name: 'gpt-5.6-sol',
|
|
global_model_display_name: 'GPT-5.6 Sol',
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: [responsesEndpoint.id],
|
|
operations: ['compact'],
|
|
}],
|
|
}
|
|
const editingGroup: AliasGroup = {
|
|
model,
|
|
apiFormatsKey: '',
|
|
apiFormats: [],
|
|
endpointIdsKey: responsesEndpoint.id,
|
|
endpointIds: [responsesEndpoint.id],
|
|
operationsKey: 'compact',
|
|
operations: ['compact'],
|
|
aliases: model.provider_model_mappings ?? [],
|
|
}
|
|
const open = ref(false)
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup() {
|
|
return () => h(ModelMappingDialog, {
|
|
open: open.value,
|
|
providerId: 'provider-1',
|
|
endpoints: [responsesEndpoint, chatEndpoint],
|
|
models: [model],
|
|
editingGroup,
|
|
'onUpdate:open': (value: boolean) => { open.value = value },
|
|
})
|
|
},
|
|
}))
|
|
app.mount(root)
|
|
mountedApps.push({ app, root })
|
|
|
|
open.value = true
|
|
await nextTick()
|
|
await nextTick()
|
|
expect(root.textContent).toContain('仅会话压缩')
|
|
|
|
root.querySelector<HTMLButtonElement>('[data-endpoint-id="endpoint-chat"]')?.click()
|
|
await nextTick()
|
|
expect(root.textContent).not.toContain('仅会话压缩')
|
|
expect(root.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]')?.textContent)
|
|
.toContain('所有请求')
|
|
|
|
const saveButton = [...root.querySelectorAll('button')]
|
|
.find(button => button.textContent?.includes('保存映射'))
|
|
saveButton?.click()
|
|
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
|
|
|
|
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: [chatEndpoint.id],
|
|
}],
|
|
})
|
|
})
|
|
|
|
it('preserves an edited compact scope when endpoint capabilities are unavailable', async () => {
|
|
const model: Model = {
|
|
provider_id: 'provider-1',
|
|
global_model_id: 'global-model-1',
|
|
is_active: true,
|
|
is_available: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
id: 'model-sol',
|
|
provider_model_name: 'gpt-5.6-sol',
|
|
global_model_display_name: 'GPT-5.6 Sol',
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: ['endpoint-responses'],
|
|
operations: ['compact'],
|
|
}],
|
|
}
|
|
const editingGroup: AliasGroup = {
|
|
model,
|
|
apiFormatsKey: '',
|
|
apiFormats: [],
|
|
endpointIdsKey: 'endpoint-responses',
|
|
endpointIds: ['endpoint-responses'],
|
|
operationsKey: 'compact',
|
|
operations: ['compact'],
|
|
aliases: model.provider_model_mappings ?? [],
|
|
}
|
|
const open = ref(false)
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup() {
|
|
return () => h(ModelMappingDialog, {
|
|
open: open.value,
|
|
providerId: 'provider-1',
|
|
endpoints: [],
|
|
models: [model],
|
|
editingGroup,
|
|
'onUpdate:open': (value: boolean) => { open.value = value },
|
|
})
|
|
},
|
|
}))
|
|
app.mount(root)
|
|
mountedApps.push({ app, root })
|
|
|
|
open.value = true
|
|
await nextTick()
|
|
await nextTick()
|
|
|
|
const saveButton = [...root.querySelectorAll('button')]
|
|
.find(button => button.textContent?.includes('保存映射'))
|
|
saveButton?.click()
|
|
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
|
|
|
|
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: ['endpoint-responses'],
|
|
operations: ['compact'],
|
|
}],
|
|
})
|
|
})
|
|
|
|
it('normalizes and replaces an edited compact operation scope', async () => {
|
|
const endpoint = {
|
|
id: 'endpoint-responses',
|
|
api_format: 'openai:responses',
|
|
base_url: 'https://api.example.com/v1',
|
|
is_active: true,
|
|
} as ProviderEndpoint
|
|
const model: Model = {
|
|
provider_id: 'provider-1',
|
|
global_model_id: 'global-model-1',
|
|
is_active: true,
|
|
is_available: true,
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
id: 'model-sol',
|
|
provider_model_name: 'gpt-5.6-sol',
|
|
global_model_display_name: 'GPT-5.6 Sol',
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: [endpoint.id],
|
|
operations: ['Compact'],
|
|
}],
|
|
}
|
|
const editingGroup: AliasGroup = {
|
|
model,
|
|
apiFormatsKey: '',
|
|
apiFormats: [],
|
|
endpointIdsKey: endpoint.id,
|
|
endpointIds: [endpoint.id],
|
|
operationsKey: 'Compact',
|
|
operations: ['Compact'],
|
|
aliases: model.provider_model_mappings ?? [],
|
|
}
|
|
const open = ref(false)
|
|
const root = document.createElement('div')
|
|
document.body.appendChild(root)
|
|
const app = createApp(defineComponent({
|
|
setup() {
|
|
return () => h(ModelMappingDialog, {
|
|
open: open.value,
|
|
providerId: 'provider-1',
|
|
endpoints: [endpoint],
|
|
models: [model],
|
|
editingGroup,
|
|
'onUpdate:open': (value: boolean) => { open.value = value },
|
|
})
|
|
},
|
|
}))
|
|
app.mount(root)
|
|
mountedApps.push({ app, root })
|
|
|
|
open.value = true
|
|
await nextTick()
|
|
await nextTick()
|
|
expect(root.textContent).toContain('仅会话压缩')
|
|
|
|
const scopeButtons = [...root.querySelectorAll('button')]
|
|
scopeButtons.find(button => button.textContent?.includes('所有请求'))?.click()
|
|
await nextTick()
|
|
scopeButtons.find(button => button.textContent?.includes('仅会话压缩'))?.click()
|
|
await nextTick()
|
|
const saveButton = [...root.querySelectorAll('button')]
|
|
.find(button => button.textContent?.includes('保存映射'))
|
|
expect(saveButton).toBeDefined()
|
|
saveButton?.click()
|
|
await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
|
|
|
|
expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
|
|
provider_model_mappings: [{
|
|
name: 'gpt-5.6-luna',
|
|
priority: 1,
|
|
endpoint_ids: [endpoint.id],
|
|
operations: ['compact'],
|
|
}],
|
|
})
|
|
})
|
|
})
|