Files
sub2api/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
T

448 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})