fix(codex): restore recovered quota status and stabilize lifecycle tests

This commit is contained in:
ZheFox
2026-09-24 10:33:45 +08:00
parent 57f53903f5
commit 31d5e2d172
14 changed files with 955 additions and 65 deletions
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, patchMock } = vi.hoisted(() => ({ getMock: vi.fn(), patchMock: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: getMock, patch: patchMock } }))
import { getPoolOverview, listPoolKeys, listPoolScores } from '@/api/endpoints/pool'
import { getProvider, updateProvider } from '@/api/endpoints/providers'
import { cache } from '@/utils/cache'
const options = { cacheTtlMs: 30_000 }
const provider = { id: 'codex', pool_advanced: { reserve_minimum_quota: false } }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
beforeEach(() => {
cache.clear()
getMock.mockReset()
patchMock.mockReset()
patchMock.mockResolvedValue({ data: provider })
})
describe('provider pool settings cache invalidation', () => {
it('refreshes every cached key page, score page and overview after changing pool settings', async () => {
getMock.mockResolvedValue({ data: { version: 'old' } })
await getPoolOverview(options)
await listPoolKeys('codex', {}, options)
await listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options)
await listPoolScores('codex', {}, options)
await listPoolKeys('codex-other', {}, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
getMock.mockResolvedValue({ data: { version: 'new' } })
await expect(getPoolOverview(options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options))
.resolves.toEqual({ version: 'new' })
await expect(listPoolScores('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex-other', {}, options)).resolves.toEqual({ version: 'old' })
})
it('does not reuse or cache a key request started before the settings were saved', async () => {
const oldResponse = deferred<{ data: { version: string } }>()
const newResponse = deferred<{ data: { version: string } }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockReturnValueOnce(newResponse.promise)
const oldRequest = listPoolKeys('codex', { page: 2 }, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { version: 'old' } })
await oldRequest
const deduped = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
newResponse.resolve({ data: { version: 'new' } })
await expect(newRequest).resolves.toEqual({ version: 'new' })
await expect(deduped).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2 }, options)).resolves.toEqual({ version: 'new' })
})
it('does not reuse a provider detail request started before a successful save', async () => {
const oldResponse = deferred<{ data: typeof provider }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockResolvedValueOnce({ data: provider })
const oldRequest = getProvider('codex')
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = getProvider('codex')
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { ...provider, pool_advanced: { reserve_minimum_quota: true } } })
await oldRequest
await expect(newRequest).resolves.toMatchObject(provider)
})
})
+10 -1
View File
@@ -1,5 +1,5 @@
import client from '../client'
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
import { buildCacheKey, cache, cachedRequest, dedupedRequest } from '@/utils/cache'
import type {
ClaudeCodeAdvancedConfig,
FailoverRulesConfig,
@@ -142,6 +142,15 @@ export async function updateProvider(
requestOptions?: ProviderRequestOptions,
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}`, data, requestOptions)
cache.delete(`providers:detail:${providerId}`)
if ('pool_advanced' in data) {
cache.delete('pool:overview')
for (const kind of ['keys', 'scores']) {
const prefix = `pool:${kind}:${providerId}`
cache.delete(prefix)
cache.deleteByPrefix(`${prefix}:`)
}
}
return normalizeProviderSummary(response.data)
}
+8
View File
@@ -61,6 +61,14 @@ class MemoryCache {
this.keyVersions.set(key, (this.keyVersions.get(key) ?? 0) + 1)
}
/** 删除匹配前缀的缓存及进行中请求,旧响应不再回填缓存。 */
deleteByPrefix(prefix: string): void {
const keys = new Set([...this.cache.keys(), ...this.inFlight.keys()])
for (const key of keys) {
if (key.startsWith(prefix)) this.delete(key)
}
}
/**
* 清空所有缓存
*/
+3 -1
View File
@@ -1395,6 +1395,8 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
if (!selectedProviderId.value || updatedProvider.id !== selectedProviderId.value) return
// 保存前发出的详情读取不得覆盖这次保存返回的新配置。
providerDataRequestId += 1
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
@@ -1403,7 +1405,7 @@ async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSumma
}
showSchedulingDialog.value = false
showAdvancedDialog.value = false
await loadOverview({ silent: true })
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
}
// --- Provider Selection ---
@@ -340,12 +340,24 @@ vi.mock('@/components/ui/refresh-button.vue', async () => {
})
vi.mock('@/features/pool/components/PoolSchedulingDialog.vue', async () => {
const { defineComponent } = await import('vue')
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'PoolSchedulingDialogStub',
setup() {
return () => null
props: {
providerId: { type: String, required: true },
currentConfig: { type: Object, default: null },
},
emits: ['saved'],
setup(props, { emit }) {
return () => h('button', {
'data-testid': 'save-pool-scheduling',
'data-reserve': String(props.currentConfig?.reserve_minimum_quota ?? false),
onClick: () => emit('saved', {
id: props.providerId,
pool_advanced: { ...props.currentConfig, reserve_minimum_quota: false },
}),
}, '保存调度测试')
},
}),
}
@@ -1278,4 +1290,80 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(disabledRoot.querySelector('[data-testid="pool-demand-metrics-button"]')).toBeNull()
})
it('reloads account scheduling state immediately after disabling minimum quota reserve', async () => {
const key = createPoolKey('codex', {
scheduling_status: 'blocked',
scheduling_reason: 'account_quota_exhausted',
scheduling_label: '额度耗尽',
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(key))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('tbody')?.textContent).toContain('额度耗尽')
endpointMocks.listPoolKeys.mockClear()
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage({
...key,
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
}))
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
expect(endpointMocks.listPoolKeys).toHaveBeenCalledWith(
'codex-provider', expect.anything(), { cacheTtlMs: 0 },
)
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
})
it('shows 17% remaining without marking a schedulable account exhausted when reserve is enabled', async () => {
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
pool_advanced: { reserve_minimum_quota: true },
}))
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey('codex', {
scheduling_status: 'available',
scheduling_reason: 'available',
scheduling_label: '可用',
status_snapshot: {
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'codex',
windows: [{ code: '5h', used_ratio: 0.83, remaining_ratio: 0.17 }],
},
oauth: { code: 'none' },
},
})))
const root = mountPoolManagement()
await settle()
expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('17.0%')
expect(root.querySelector('tbody')?.textContent).toContain('可用')
expect(root.querySelector('tbody')?.textContent).not.toContain('额度耗尽')
})
it('does not replace saved scheduling settings with an older provider detail response', async () => {
let resolveOldProvider!: (provider: ReturnType<typeof createProvider>) => void
endpointMocks.getProvider.mockReturnValue(new Promise((resolve) => { resolveOldProvider = resolve }))
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(createPoolKey()))
const root = mountPoolManagement()
await settle()
root.querySelector<HTMLButtonElement>('[data-testid="save-pool-scheduling"]')?.click()
await settle()
resolveOldProvider(createProvider('codex', { pool_advanced: { reserve_minimum_quota: true } }))
await settle()
expect(root.querySelector('[data-testid="save-pool-scheduling"]')?.getAttribute('data-reserve')).toBe('false')
})
})