mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(codex): restore recovered quota status and stabilize lifecycle tests
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user