664 lines
25 KiB
TypeScript
664 lines
25 KiB
TypeScript
/* eslint-disable max-lines -- Why: these tests mirror the fetch ordering,
|
|
stale-data handling, account-switch generation, and OpenCode config-change
|
|
semantics covered in service.ts, which already carries the same pragma.
|
|
Keeping them in one file makes the ordering contract reviewable as a unit. */
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
|
import { RateLimitService } from './service'
|
|
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
|
import { fetchCodexRateLimits } from './codex-fetcher'
|
|
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
|
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
|
|
|
vi.mock('./claude-fetcher', () => ({
|
|
fetchClaudeRateLimits: vi.fn(),
|
|
fetchManagedAccountUsage: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./codex-fetcher', () => ({
|
|
fetchCodexRateLimits: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./gemini-usage-fetcher', () => ({
|
|
fetchGeminiRateLimits: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./opencode-go-usage-fetcher', () => ({
|
|
fetchOpenCodeGoRateLimits: vi.fn()
|
|
}))
|
|
|
|
type Deferred<T> = {
|
|
promise: Promise<T>
|
|
resolve: (value: T) => void
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function okProvider(
|
|
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
|
usedPercent: number,
|
|
updatedAt = Date.now()
|
|
): ProviderRateLimits {
|
|
return {
|
|
provider,
|
|
session: {
|
|
usedPercent,
|
|
windowMinutes: 300,
|
|
resetsAt: null,
|
|
resetDescription: null
|
|
},
|
|
weekly: null,
|
|
updatedAt,
|
|
error: null,
|
|
status: 'ok'
|
|
}
|
|
}
|
|
|
|
function errorProvider(
|
|
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
|
message: string
|
|
): ProviderRateLimits {
|
|
return {
|
|
provider,
|
|
session: null,
|
|
weekly: null,
|
|
updatedAt: Date.now(),
|
|
error: message,
|
|
status: 'error'
|
|
}
|
|
}
|
|
|
|
function serviceInternals(service: RateLimitService): { fetchAll: () => Promise<void> } {
|
|
return service as unknown as { fetchAll: () => Promise<void> }
|
|
}
|
|
|
|
describe('RateLimitService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
|
})
|
|
|
|
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
|
|
const service = new RateLimitService()
|
|
const firstClaude = deferred<ProviderRateLimits>()
|
|
const firstCodex = deferred<ProviderRateLimits>()
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockImplementationOnce(() => firstClaude.promise)
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockImplementationOnce(() => firstCodex.promise)
|
|
.mockResolvedValueOnce(okProvider('codex', 42))
|
|
|
|
const fullRefresh = service.refresh()
|
|
await Promise.resolve()
|
|
|
|
const switchRefresh = service.refreshForCodexAccountChange()
|
|
await Promise.resolve()
|
|
|
|
firstClaude.resolve(okProvider('claude', 18))
|
|
firstCodex.resolve(okProvider('codex', 24))
|
|
|
|
await fullRefresh
|
|
await switchRefresh
|
|
|
|
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('keeps recent stale data across repeated failures', async () => {
|
|
const service = new RateLimitService()
|
|
const internal = serviceInternals(service)
|
|
|
|
vi.mocked(fetchClaudeRateLimits)
|
|
.mockResolvedValueOnce(okProvider('claude', 33, Date.now()))
|
|
.mockResolvedValueOnce(errorProvider('claude', 'temporary failure'))
|
|
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
|
|
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockResolvedValueOnce(okProvider('codex', 44, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('codex', 44, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('codex', 44, Date.now()))
|
|
|
|
await internal.fetchAll()
|
|
await internal.fetchAll()
|
|
|
|
let state = service.getState()
|
|
expect(state.claude?.status).toBe('error')
|
|
expect(state.claude?.session?.usedPercent).toBe(33)
|
|
|
|
await internal.fetchAll()
|
|
|
|
state = service.getState()
|
|
expect(state.claude?.status).toBe('error')
|
|
expect(state.claude?.session?.usedPercent).toBe(33)
|
|
expect(state.claude?.error).toBe('still failing')
|
|
})
|
|
|
|
it('bypasses the debounce for explicit manual refreshes', async () => {
|
|
const service = new RateLimitService()
|
|
|
|
vi.mocked(fetchClaudeRateLimits)
|
|
.mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('claude', 11, Date.now()))
|
|
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('codex', 21, Date.now()))
|
|
|
|
await service.refresh()
|
|
await service.refresh()
|
|
|
|
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('waits for a queued explicit refresh when another fetch is already in flight', async () => {
|
|
const service = new RateLimitService()
|
|
const firstClaude = deferred<ProviderRateLimits>()
|
|
const firstCodex = deferred<ProviderRateLimits>()
|
|
const secondClaude = deferred<ProviderRateLimits>()
|
|
const secondCodex = deferred<ProviderRateLimits>()
|
|
|
|
vi.mocked(fetchClaudeRateLimits)
|
|
.mockImplementationOnce(() => firstClaude.promise)
|
|
.mockImplementationOnce(() => secondClaude.promise)
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockImplementationOnce(() => firstCodex.promise)
|
|
.mockImplementationOnce(() => secondCodex.promise)
|
|
|
|
const backgroundFetch = serviceInternals(service).fetchAll()
|
|
await Promise.resolve()
|
|
|
|
let refreshResolved = false
|
|
const manualRefresh = service.refresh().then(() => {
|
|
refreshResolved = true
|
|
})
|
|
await Promise.resolve()
|
|
|
|
firstClaude.resolve(okProvider('claude', 10, Date.now()))
|
|
firstCodex.resolve(okProvider('codex', 20, Date.now()))
|
|
await Promise.resolve()
|
|
|
|
expect(refreshResolved).toBe(false)
|
|
|
|
secondClaude.resolve(okProvider('claude', 11, Date.now()))
|
|
secondCodex.resolve(okProvider('codex', 21, Date.now()))
|
|
await backgroundFetch
|
|
await manualRefresh
|
|
|
|
expect(refreshResolved).toBe(true)
|
|
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => {
|
|
const service = new RateLimitService()
|
|
service.setSettingsResolver(() => ({
|
|
opencodeSessionCookie: 'session=abc123',
|
|
opencodeWorkspaceId: ''
|
|
}))
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now()))
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
|
okProvider('opencode-go', 40, Date.now())
|
|
)
|
|
|
|
await service.refresh()
|
|
|
|
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
|
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
|
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
|
|
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined)
|
|
|
|
const state = service.getState()
|
|
expect(state.claude?.status).toBe('ok')
|
|
expect(state.claude?.session?.usedPercent).toBe(10)
|
|
expect(state.codex?.status).toBe('ok')
|
|
expect(state.codex?.session?.usedPercent).toBe(20)
|
|
expect(state.gemini?.status).toBe('ok')
|
|
expect(state.gemini?.session?.usedPercent).toBe(30)
|
|
expect(state.opencodeGo?.status).toBe('ok')
|
|
expect(state.opencodeGo?.session?.usedPercent).toBe(40)
|
|
})
|
|
|
|
it('passes the selected WSL Codex home into active account rate-limit fetches', async () => {
|
|
const service = new RateLimitService()
|
|
const wslCodexHome =
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
|
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
|
|
const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome))
|
|
service.setCodexHomePathResolver(resolver)
|
|
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
|
|
await service.refreshForCodexAccountChange(null, { runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
|
|
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
|
expect.objectContaining({ codexHomePath: wslCodexHome })
|
|
)
|
|
})
|
|
|
|
it('uses the initialized WSL target for active Codex rate-limit fetches', async () => {
|
|
const service = new RateLimitService()
|
|
const wslCodexHome =
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
|
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
|
|
const resolver = vi.fn((target) => (target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome))
|
|
service.setCodexHomePathResolver(resolver)
|
|
service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
|
|
await service.refresh()
|
|
|
|
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
|
expect.objectContaining({ codexHomePath: wslCodexHome })
|
|
)
|
|
})
|
|
|
|
it('does not fetch host Codex usage when WSL home resolution fails', async () => {
|
|
const service = new RateLimitService()
|
|
const resolver = vi.fn(() => null)
|
|
service.setCodexHomePathResolver(resolver)
|
|
service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
|
|
await service.refresh()
|
|
|
|
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
expect(fetchCodexRateLimits).not.toHaveBeenCalled()
|
|
expect(service.getState().codex).toMatchObject({
|
|
provider: 'codex',
|
|
status: 'error',
|
|
error: 'WSL Codex home unavailable for Ubuntu'
|
|
})
|
|
})
|
|
|
|
it('uses the initialized WSL target for active Claude rate-limit fetches', async () => {
|
|
const service = new RateLimitService()
|
|
const resolver = vi.fn(async (target) => ({
|
|
configDir:
|
|
target?.runtime === 'wsl'
|
|
? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude'
|
|
: 'C:\\Users\\jin\\.claude',
|
|
runtime: target?.runtime ?? 'host',
|
|
wslDistro: target?.wslDistro ?? null,
|
|
wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null,
|
|
envPatch: target?.runtime === 'wsl' ? { CLAUDE_CONFIG_DIR: '/home/jin/.claude' } : {},
|
|
stripAuthEnv: target?.runtime === 'wsl',
|
|
provenance: target?.runtime === 'wsl' ? 'managed:wsl-account:wsl:Ubuntu' : 'system'
|
|
}))
|
|
service.setClaudeAuthPreparationResolver(resolver)
|
|
service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
|
|
await service.refresh()
|
|
|
|
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
|
authPreparation: expect.objectContaining({
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu',
|
|
wslLinuxConfigDir: '/home/jin/.claude',
|
|
stripAuthEnv: true
|
|
})
|
|
})
|
|
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
|
})
|
|
|
|
it('does not cache host Codex usage under an outgoing WSL account', async () => {
|
|
const service = new RateLimitService()
|
|
const wslCodexHome =
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
|
const hostCodexHome = 'C:\\Users\\jin\\.orca\\codex-accounts\\host\\home'
|
|
service.setCodexHomePathResolver((target) =>
|
|
target?.runtime === 'wsl' ? wslCodexHome : hostCodexHome
|
|
)
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('codex', 40, Date.now()))
|
|
|
|
await service.refresh()
|
|
await service.refreshForCodexAccountChange('wsl-account-1', {
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
|
|
expect(service.getState().inactiveCodexAccounts).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })])
|
|
)
|
|
})
|
|
|
|
it('does not cache host Claude usage under an outgoing WSL account', async () => {
|
|
const service = new RateLimitService()
|
|
service.setInactiveClaudeAccountsResolver(() => [
|
|
{ id: 'wsl-account-1', managedAuthPath: '/tmp/account-1/auth' }
|
|
])
|
|
service.setClaudeAuthPreparationResolver(async (target) => ({
|
|
configDir:
|
|
target?.runtime === 'wsl'
|
|
? '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude'
|
|
: 'C:\\Users\\jin\\.claude',
|
|
runtime: target?.runtime ?? 'host',
|
|
wslDistro: target?.wslDistro ?? null,
|
|
wslLinuxConfigDir: target?.runtime === 'wsl' ? '/home/jin/.claude' : null,
|
|
envPatch: {},
|
|
stripAuthEnv: target?.runtime === 'wsl',
|
|
provenance: target?.runtime === 'wsl' ? 'managed:wsl-account-1:wsl:Ubuntu' : 'system'
|
|
}))
|
|
|
|
vi.mocked(fetchClaudeRateLimits)
|
|
.mockResolvedValueOnce(okProvider('claude', 20, Date.now()))
|
|
.mockResolvedValueOnce(okProvider('claude', 40, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
|
|
await service.refresh()
|
|
await service.refreshForClaudeAccountChange('wsl-account-1', {
|
|
runtime: 'wsl',
|
|
wslDistro: 'Ubuntu'
|
|
})
|
|
|
|
expect(service.getState().inactiveClaudeAccounts).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })])
|
|
)
|
|
})
|
|
|
|
it('passes WSL Codex managed homes into inactive account rate-limit fetches', async () => {
|
|
const service = new RateLimitService()
|
|
const wslCodexHome =
|
|
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
|
|
service.setInactiveCodexAccountsResolver(() => [
|
|
{ id: 'account-1', managedHomePath: wslCodexHome }
|
|
])
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 33, Date.now()))
|
|
|
|
await service.fetchInactiveCodexAccountsOnOpen()
|
|
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledWith({
|
|
codexHomePath: wslCodexHome,
|
|
allowPtyFallback: false
|
|
})
|
|
expect(service.getState().inactiveCodexAccounts).toEqual([
|
|
{
|
|
accountId: 'account-1',
|
|
claude: expect.objectContaining({
|
|
session: expect.objectContaining({ usedPercent: 33 })
|
|
}),
|
|
updatedAt: expect.any(Number),
|
|
isFetching: false
|
|
}
|
|
])
|
|
})
|
|
|
|
it('does not start overlapping inactive Codex preview fetches', async () => {
|
|
const service = new RateLimitService()
|
|
const accountFetch = deferred<ProviderRateLimits>()
|
|
service.setInactiveCodexAccountsResolver(() => [
|
|
{ id: 'account-1', managedHomePath: '/tmp/account-1/home' }
|
|
])
|
|
vi.mocked(fetchCodexRateLimits).mockReturnValueOnce(accountFetch.promise)
|
|
|
|
const firstFetch = service.fetchInactiveCodexAccountsOnOpen()
|
|
await Promise.resolve()
|
|
await service.fetchInactiveCodexAccountsOnOpen()
|
|
|
|
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
|
|
|
accountFetch.resolve(okProvider('codex', 50, Date.now()))
|
|
await firstFetch
|
|
})
|
|
|
|
it('does not recache an inactive Codex account that becomes active during fetch-on-open', async () => {
|
|
const service = new RateLimitService()
|
|
const accountFetch = deferred<ProviderRateLimits>()
|
|
let inactiveAccounts = [{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }]
|
|
service.setInactiveCodexAccountsResolver(() => inactiveAccounts)
|
|
service.setCodexHomePathResolver(() => '/tmp/account-b/home')
|
|
vi.mocked(fetchCodexRateLimits)
|
|
.mockReturnValueOnce(accountFetch.promise)
|
|
.mockResolvedValueOnce(okProvider('codex', 7, Date.now()))
|
|
|
|
const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen()
|
|
await Promise.resolve()
|
|
expect(service.getState().inactiveCodexAccounts).toEqual([
|
|
{ accountId: 'account-b', claude: null, updatedAt: 0, isFetching: true }
|
|
])
|
|
|
|
inactiveAccounts = []
|
|
await service.refreshForCodexAccountChange('account-a')
|
|
accountFetch.resolve(okProvider('codex', 42, Date.now()))
|
|
await fetchOnOpen
|
|
|
|
expect(service.getState().inactiveCodexAccounts).toEqual([])
|
|
})
|
|
|
|
it('preserves Gemini buckets through getState after fetch', async () => {
|
|
const service = new RateLimitService()
|
|
|
|
const geminiWithBuckets: ProviderRateLimits = {
|
|
provider: 'gemini',
|
|
session: { usedPercent: 80, windowMinutes: 300, resetsAt: null, resetDescription: null },
|
|
weekly: null,
|
|
buckets: [
|
|
{
|
|
name: 'Pro',
|
|
usedPercent: 30,
|
|
windowMinutes: 300,
|
|
resetsAt: null,
|
|
resetDescription: null
|
|
},
|
|
{
|
|
name: 'Flash',
|
|
usedPercent: 80,
|
|
windowMinutes: 300,
|
|
resetsAt: null,
|
|
resetDescription: null
|
|
}
|
|
],
|
|
updatedAt: Date.now(),
|
|
error: null,
|
|
status: 'ok'
|
|
}
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(geminiWithBuckets)
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
|
okProvider('opencode-go', 0, Date.now())
|
|
)
|
|
|
|
await service.refresh()
|
|
|
|
const state = service.getState()
|
|
expect(state.gemini?.buckets).toHaveLength(2)
|
|
expect(state.gemini?.buckets![0].name).toBe('Pro')
|
|
expect(state.gemini?.buckets![1].name).toBe('Flash')
|
|
// Why: session summary is derived from bucket data and must match the most constrained bucket.
|
|
expect(state.gemini?.session?.usedPercent).toBe(80)
|
|
})
|
|
|
|
it('isolates provider failures so one error does not block others', async () => {
|
|
const service = new RateLimitService()
|
|
service.setSettingsResolver(() => ({ opencodeSessionCookie: '', opencodeWorkspaceId: '' }))
|
|
|
|
vi.mocked(fetchClaudeRateLimits).mockRejectedValueOnce(new Error('claude down'))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
|
vi.mocked(fetchGeminiRateLimits).mockRejectedValueOnce(new Error('gemini down'))
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValueOnce(
|
|
okProvider('opencode-go', 40, Date.now())
|
|
)
|
|
|
|
await service.refresh()
|
|
|
|
const state = service.getState()
|
|
expect(state.claude?.status).toBe('error')
|
|
expect(state.claude?.error).toBe('claude down')
|
|
expect(state.codex?.status).toBe('ok')
|
|
expect(state.gemini?.status).toBe('error')
|
|
expect(state.gemini?.error).toBe('gemini down')
|
|
expect(state.opencodeGo?.status).toBe('ok')
|
|
})
|
|
|
|
it('discards stale data when a provider becomes unavailable', async () => {
|
|
const service = new RateLimitService()
|
|
let cookie = 'session=valid'
|
|
service.setSettingsResolver(() => ({ opencodeSessionCookie: cookie, opencodeWorkspaceId: '' }))
|
|
|
|
// 1. Success fetch
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now()))
|
|
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20, Date.now()))
|
|
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 30, Date.now()))
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
|
okProvider('opencode-go', 40, Date.now())
|
|
)
|
|
|
|
await service.refresh()
|
|
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40)
|
|
|
|
// 2. Clear cookie -> should become unavailable and LOSE the 40% data
|
|
cookie = ''
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({
|
|
provider: 'opencode-go',
|
|
session: null,
|
|
weekly: null,
|
|
monthly: null,
|
|
updatedAt: Date.now(),
|
|
error: 'Session cookie not configured',
|
|
status: 'unavailable'
|
|
})
|
|
|
|
await service.refresh()
|
|
const state = service.getState()
|
|
expect(state.opencodeGo?.status).toBe('unavailable')
|
|
expect(state.opencodeGo?.session).toBeNull()
|
|
expect(state.opencodeGo?.error).toBe('Session cookie not configured')
|
|
})
|
|
|
|
it('discards stale data when Workspace ID override is changed', async () => {
|
|
const service = new RateLimitService()
|
|
let workspaceId = 'wrk_A'
|
|
service.setSettingsResolver(() => ({
|
|
opencodeSessionCookie: 'session=valid',
|
|
opencodeWorkspaceId: workspaceId
|
|
}))
|
|
|
|
// 1. Success fetch for Workspace A
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
|
okProvider('opencode-go', 40, Date.now())
|
|
)
|
|
await service.refresh()
|
|
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(40)
|
|
|
|
// 2. Change Workspace ID to B -> old data from A should be discarded
|
|
workspaceId = 'wrk_B'
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(
|
|
okProvider('opencode-go', 10, Date.now())
|
|
)
|
|
await service.refresh()
|
|
expect(service.getState().opencodeGo?.session?.usedPercent).toBe(10)
|
|
|
|
// 3. Clear Workspace ID (automatic) but it fails -> should show error, NOT stale data from B
|
|
workspaceId = ''
|
|
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue({
|
|
provider: 'opencode-go',
|
|
session: null,
|
|
weekly: null,
|
|
monthly: null,
|
|
updatedAt: Date.now(),
|
|
error: 'No workspace ID found',
|
|
status: 'error'
|
|
})
|
|
await service.refresh()
|
|
const state = service.getState()
|
|
expect(state.opencodeGo?.status).toBe('error')
|
|
expect(state.opencodeGo?.session).toBeNull()
|
|
expect(state.opencodeGo?.error).toBe('No workspace ID found')
|
|
})
|
|
|
|
it('does not recache an inactive Claude account removed during fetch-on-open', async () => {
|
|
const service = new RateLimitService()
|
|
const accountFetch = deferred<ProviderRateLimits>()
|
|
let inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }]
|
|
service.setInactiveClaudeAccountsResolver(() => inactiveAccounts)
|
|
service.setClaudeAuthPreparationResolver(async () => ({
|
|
configDir: '/tmp/.claude',
|
|
envPatch: {},
|
|
stripAuthEnv: false,
|
|
provenance: 'system'
|
|
}))
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
|
|
await service.refresh()
|
|
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
|
|
|
|
const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen()
|
|
await Promise.resolve()
|
|
expect(service.getState().inactiveClaudeAccounts).toEqual([
|
|
{ accountId: 'account-1', claude: null, updatedAt: 0, isFetching: true }
|
|
])
|
|
|
|
service.evictInactiveClaudeCache('account-1')
|
|
inactiveAccounts = [{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }]
|
|
await service.refreshForClaudeAccountChange('account-1')
|
|
expect(service.getState().inactiveClaudeAccounts[0]?.accountId).toBe('account-1')
|
|
|
|
inactiveAccounts = []
|
|
service.evictInactiveClaudeCache('account-1')
|
|
accountFetch.resolve(okProvider('claude', 42))
|
|
await fetchOnOpen
|
|
|
|
expect(service.getState().inactiveClaudeAccounts).toEqual([])
|
|
})
|
|
|
|
it('does not overwrite inactive Claude cache from a stale same-id fetch', async () => {
|
|
const service = new RateLimitService()
|
|
const accountFetch = deferred<ProviderRateLimits>()
|
|
service.setInactiveClaudeAccountsResolver(() => [
|
|
{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
|
|
])
|
|
service.setClaudeAuthPreparationResolver(async () => ({
|
|
configDir: '/tmp/.claude',
|
|
envPatch: {},
|
|
stripAuthEnv: false,
|
|
provenance: 'system'
|
|
}))
|
|
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7))
|
|
await service.refresh()
|
|
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
|
|
|
|
const fetchOnOpen = service.fetchInactiveClaudeAccountsOnOpen()
|
|
await Promise.resolve()
|
|
|
|
await service.refreshForClaudeAccountChange('account-1')
|
|
accountFetch.resolve(okProvider('claude', 42))
|
|
await fetchOnOpen
|
|
|
|
expect(service.getState().inactiveClaudeAccounts).toEqual([
|
|
{
|
|
accountId: 'account-1',
|
|
claude: expect.objectContaining({
|
|
session: expect.objectContaining({ usedPercent: 7 })
|
|
}),
|
|
updatedAt: expect.any(Number),
|
|
isFetching: false
|
|
}
|
|
])
|
|
})
|
|
})
|