mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
import { defineComponent } from 'vue'
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const {
|
||
createAccountMock,
|
||
probeUpstreamBillingMock,
|
||
importCodexSessionMock,
|
||
createOpenAICodexPATMock,
|
||
authIsSimpleMode,
|
||
} = vi.hoisted(() => ({
|
||
createAccountMock: vi.fn(),
|
||
probeUpstreamBillingMock: vi.fn(),
|
||
importCodexSessionMock: vi.fn(),
|
||
createOpenAICodexPATMock: vi.fn(),
|
||
authIsSimpleMode: { value: true },
|
||
}))
|
||
|
||
vi.mock('@/stores/app', () => ({
|
||
useAppStore: () => ({
|
||
showError: vi.fn(),
|
||
showSuccess: vi.fn(),
|
||
showWarning: vi.fn(),
|
||
}),
|
||
}))
|
||
|
||
vi.mock('@/stores/auth', () => ({
|
||
useAuthStore: () => ({
|
||
get isSimpleMode() {
|
||
return authIsSimpleMode.value
|
||
},
|
||
}),
|
||
}))
|
||
|
||
vi.mock('@/api/admin', () => ({
|
||
adminAPI: {
|
||
accounts: {
|
||
create: createAccountMock,
|
||
probeUpstreamBilling: probeUpstreamBillingMock,
|
||
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
|
||
importCodexSession: importCodexSessionMock,
|
||
createOpenAICodexPAT: createOpenAICodexPATMock,
|
||
},
|
||
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().mockResolvedValue([]),
|
||
}))
|
||
|
||
vi.mock('vue-i18n', async () => {
|
||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||
return {
|
||
...actual,
|
||
useI18n: () => ({ t: (key: string) => key }),
|
||
}
|
||
})
|
||
|
||
import CreateAccountModal from '../CreateAccountModal.vue'
|
||
|
||
const BaseDialogStub = defineComponent({
|
||
name: 'BaseDialog',
|
||
props: { show: { type: Boolean, default: false } },
|
||
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||
})
|
||
|
||
const OAuthAuthorizationFlowStub = defineComponent({
|
||
name: 'OAuthAuthorizationFlow',
|
||
props: {
|
||
showManualOption: Boolean,
|
||
showCodexSessionImportOption: Boolean,
|
||
showAgentIdentityOption: Boolean,
|
||
showCodexPatOption: Boolean,
|
||
initialInputMethod: String,
|
||
},
|
||
data: () => ({ inputMethod: 'manual' }),
|
||
emits: ['import-codex-session', 'import-codex-pat'],
|
||
template: `
|
||
<div>
|
||
<button data-testid="import-codex-session" @click="$emit('import-codex-session', 'session-json')">session</button>
|
||
<button data-testid="import-codex-pat" @click="$emit('import-codex-pat', 'pat-token')">pat</button>
|
||
</div>
|
||
`,
|
||
})
|
||
|
||
const GroupSelectorStub = defineComponent({
|
||
name: 'GroupSelector',
|
||
props: {
|
||
modelValue: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
},
|
||
emits: ['update:modelValue'],
|
||
template: `
|
||
<button
|
||
type="button"
|
||
data-testid="select-pricing-groups"
|
||
@click="$emit('update:modelValue', [1, 2])"
|
||
>
|
||
groups
|
||
</button>
|
||
`,
|
||
})
|
||
|
||
const ModelWhitelistSelectorStub = defineComponent({
|
||
name: 'ModelWhitelistSelector',
|
||
props: {
|
||
modelValue: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
platform: String,
|
||
syncCredentials: Object,
|
||
},
|
||
emits: ['update:modelValue'],
|
||
template: '<div data-testid="model-whitelist-selector" />',
|
||
})
|
||
|
||
function mountModal(groups: any[] = []) {
|
||
return mount(CreateAccountModal, {
|
||
props: { show: true, proxies: [], groups },
|
||
global: {
|
||
stubs: {
|
||
BaseDialog: BaseDialogStub,
|
||
OAuthAuthorizationFlow: OAuthAuthorizationFlowStub,
|
||
ConfirmDialog: true,
|
||
Select: true,
|
||
Icon: true,
|
||
PlatformIcon: true,
|
||
ProxySelector: true,
|
||
ProxyAdBanner: true,
|
||
GroupSelector: GroupSelectorStub,
|
||
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||
QuotaLimitCard: true,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
async function selectButtonByText(wrapper: ReturnType<typeof mountModal>, text: string) {
|
||
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(text))
|
||
expect(button).toBeDefined()
|
||
await button?.trigger('click')
|
||
}
|
||
|
||
async function submitApiKeyAccount(
|
||
platform: 'openai' | 'anthropic',
|
||
enableLongContextBilling = false,
|
||
disableUpstreamBillingProbe = false
|
||
) {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, platform === 'openai' ? 'OpenAI' : 'admin.accounts.claudeConsole')
|
||
if (platform === 'openai') {
|
||
await selectButtonByText(wrapper, 'API Key')
|
||
}
|
||
await wrapper.get('form#create-account-form input[type="text"]').setValue(`${platform} account`)
|
||
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
|
||
if (enableLongContextBilling) {
|
||
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
|
||
}
|
||
if (disableUpstreamBillingProbe) {
|
||
await wrapper.get('[data-testid="upstream-billing-auto-probe"]').trigger('click')
|
||
}
|
||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||
await flushPromises()
|
||
return wrapper
|
||
}
|
||
|
||
async function openCodexImportStep(toggleClicks = 0) {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'OpenAI')
|
||
for (let click = 0; click < toggleClicks; click += 1) {
|
||
await wrapper.get('[data-testid="openai-long-context-billing-toggle"]').trigger('click')
|
||
}
|
||
await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import')
|
||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||
return wrapper
|
||
}
|
||
|
||
describe('CreateAccountModal OpenAI long-context billing', () => {
|
||
beforeEach(() => {
|
||
authIsSimpleMode.value = true
|
||
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
|
||
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
|
||
importCodexSessionMock.mockReset().mockResolvedValue({
|
||
created: 1,
|
||
updated: 0,
|
||
skipped: 0,
|
||
failed: 0,
|
||
errors: [],
|
||
warnings: [],
|
||
})
|
||
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
|
||
})
|
||
|
||
it('hides only the redundant account toggle when every selected group enables tier pricing', async () => {
|
||
authIsSimpleMode.value = false
|
||
const wrapper = mountModal([
|
||
{ id: 1, long_context_pricing_enabled: true },
|
||
{ id: 2, long_context_pricing_enabled: true },
|
||
])
|
||
|
||
await selectButtonByText(wrapper, 'OpenAI')
|
||
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
|
||
|
||
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(false)
|
||
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
|
||
})
|
||
|
||
it('keeps the account toggle when any selected group disables tier pricing', async () => {
|
||
authIsSimpleMode.value = false
|
||
const wrapper = mountModal([
|
||
{ id: 1, long_context_pricing_enabled: true },
|
||
{ id: 2, long_context_pricing_enabled: false },
|
||
])
|
||
|
||
await selectButtonByText(wrapper, 'OpenAI')
|
||
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
|
||
|
||
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(true)
|
||
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
|
||
})
|
||
|
||
it('sends false explicitly for normal OpenAI account creation by default', async () => {
|
||
await submitApiKeyAccount('openai')
|
||
|
||
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||
})
|
||
|
||
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
|
||
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'OpenAI')
|
||
|
||
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
|
||
true
|
||
)
|
||
|
||
await selectButtonByText(wrapper, 'API Key')
|
||
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
|
||
false
|
||
)
|
||
})
|
||
|
||
it('enables upstream billing probes by default for new OpenAI API key accounts', async () => {
|
||
await submitApiKeyAccount('openai')
|
||
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true)
|
||
})
|
||
|
||
it('waits for the initial upstream billing probe before refreshing the account list', async () => {
|
||
let resolveProbe: (() => void) | undefined
|
||
probeUpstreamBillingMock.mockImplementationOnce(
|
||
() => new Promise<void>((resolve) => {
|
||
resolveProbe = resolve
|
||
})
|
||
)
|
||
|
||
const wrapper = await submitApiKeyAccount('openai')
|
||
|
||
expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42)
|
||
expect(wrapper.emitted('created')).toBeUndefined()
|
||
|
||
resolveProbe?.()
|
||
await flushPromises()
|
||
|
||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||
})
|
||
|
||
it('sends an explicit disabled state when the create toggle is turned off', async () => {
|
||
await submitApiKeyAccount('openai', false, true)
|
||
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false)
|
||
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('submits adaptive Kimi protocol endpoints', async () => {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'Kimi')
|
||
await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi adaptive')
|
||
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
|
||
|
||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||
await flushPromises()
|
||
|
||
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
|
||
account_mode: 'payg',
|
||
api_protocol: 'adaptive',
|
||
base_url: 'https://api.moonshot.cn/v1',
|
||
api_base_urls: {
|
||
chat_completions: 'https://api.moonshot.cn/v1',
|
||
anthropic: 'https://api.moonshot.cn/anthropic'
|
||
}
|
||
})
|
||
})
|
||
|
||
it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'Kimi')
|
||
await wrapper
|
||
.get('[data-testid="cn-adaptive-base-url-chat_completions"]')
|
||
.setValue('https://relay.example.com/v1')
|
||
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-relay')
|
||
|
||
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
|
||
platform: 'kimi',
|
||
type: 'apikey',
|
||
base_url: 'https://relay.example.com/v1',
|
||
api_key: 'sk-relay'
|
||
})
|
||
})
|
||
|
||
it('exposes Agent Identity in the OpenAI authorization methods', async () => {
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'OpenAI')
|
||
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenAI account')
|
||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||
|
||
const flow = wrapper.getComponent(OAuthAuthorizationFlowStub)
|
||
expect(flow.props('showManualOption')).toBe(true)
|
||
expect(flow.props('showCodexSessionImportOption')).toBe(true)
|
||
expect(flow.props('showAgentIdentityOption')).toBe(true)
|
||
expect(flow.props('showCodexPatOption')).toBe(true)
|
||
expect(flow.props('initialInputMethod')).toBe('manual')
|
||
})
|
||
|
||
it.each([
|
||
['camelCase', { authMode: 'agentIdentity', agentIdentity: { agentRuntimeId: 'runtime' } }],
|
||
['nested identity without auth_mode', { agent_identity: { agent_runtime_id: 'runtime' } }],
|
||
])('accepts backend-compatible %s Agent Identity imports', async (_name, content) => {
|
||
const wrapper = await openCodexImportStep()
|
||
const flow = wrapper.getComponent(OAuthAuthorizationFlowStub)
|
||
flow.vm.inputMethod = 'agent_identity'
|
||
|
||
flow.vm.$emit('import-codex-session', JSON.stringify(content))
|
||
await flushPromises()
|
||
|
||
expect(importCodexSessionMock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('sends true explicitly when OpenAI long-context billing is enabled', async () => {
|
||
await submitApiKeyAccount('openai', true)
|
||
|
||
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
|
||
})
|
||
|
||
it('omits the OpenAI setting for non-OpenAI account creation', async () => {
|
||
await submitApiKeyAccount('anthropic')
|
||
|
||
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
|
||
// 上游倍率探测已放宽到全部 API-key 平台:非 OpenAI 平台与 OpenAI 一致,默认开启。
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true)
|
||
})
|
||
|
||
it('sends an explicit disabled state when the non-OpenAI create toggle is turned off', async () => {
|
||
await submitApiKeyAccount('anthropic', false, true)
|
||
|
||
expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false)
|
||
})
|
||
|
||
it('antigravity upstream 创建默认携带上游倍率探测开关', async () => {
|
||
// antigravity upstream 走独立创建 helper,
|
||
// 也必须与其余 API-key 平台一样默认开启探测并传递开关。
|
||
const wrapper = mountModal()
|
||
await selectButtonByText(wrapper, 'Antigravity')
|
||
await selectButtonByText(wrapper, 'admin.accounts.types.antigravityApikey')
|
||
await wrapper.get('form#create-account-form input[type="text"]').setValue('antigravity relay')
|
||
const baseInput = wrapper
|
||
.findAll('input')
|
||
.find((candidate) => candidate.attributes('placeholder') === 'https://cloudcode-pa.googleapis.com')
|
||
expect(baseInput).toBeDefined()
|
||
await baseInput?.setValue('https://relay.example')
|
||
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-upstream')
|
||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||
await flushPromises()
|
||
|
||
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||
const payload = createAccountMock.mock.calls[0]?.[0]
|
||
expect(payload?.platform).toBe('antigravity')
|
||
expect(payload?.type).toBe('apikey')
|
||
expect(payload?.upstream_billing_probe_enabled).toBe(true)
|
||
// 创建成功后前端立即发起一次首探(与其他 apikey 平台一致)。
|
||
expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42)
|
||
})
|
||
|
||
it('leaves Codex session import billing ownership to the backend', async () => {
|
||
const wrapper = await openCodexImportStep()
|
||
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(importCodexSessionMock).toHaveBeenCalledTimes(1)
|
||
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
|
||
})
|
||
|
||
it('leaves Codex PAT import billing ownership to the backend', async () => {
|
||
const wrapper = await openCodexImportStep()
|
||
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(createOpenAICodexPATMock).toHaveBeenCalledTimes(1)
|
||
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined()
|
||
})
|
||
|
||
it('sends explicit true for Codex session import after the toggle is enabled', async () => {
|
||
const wrapper = await openCodexImportStep(1)
|
||
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
|
||
})
|
||
|
||
it('sends explicit false for Codex session import after the toggle is changed back', async () => {
|
||
const wrapper = await openCodexImportStep(2)
|
||
await wrapper.get('[data-testid="import-codex-session"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||
})
|
||
|
||
it('sends explicit true for Codex PAT import after the toggle is enabled', async () => {
|
||
const wrapper = await openCodexImportStep(1)
|
||
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(true)
|
||
})
|
||
|
||
it('sends explicit false for Codex PAT import after the toggle is changed back', async () => {
|
||
const wrapper = await openCodexImportStep(2)
|
||
await wrapper.get('[data-testid="import-codex-pat"]').trigger('click')
|
||
await flushPromises()
|
||
|
||
expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||
})
|
||
})
|