mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 18:15:15 +00:00
Expose per-window detail (5h / weekly / Opus / Sonnet) on native subscription rows instead of collapsing to a single percentage, so the bar can show the binding window, resets, and a burn-rate pace line. Fix the Codex collector to scan recent session rollouts newest-first for the latest non-null rate_limits (today's exec-mode session is null) and mark the result stale with its source time, so Codex quota appears instead of silently vanishing.
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
/**
|
|
* Tests for the native subscription quota collector.
|
|
*
|
|
* The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage
|
|
* endpoint. A controllable clock drives TTL / backoff / breaker assertions.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
import {
|
|
getNativeAccountRows,
|
|
resetNativeQuotaState,
|
|
type NativeQuotaDeps,
|
|
} from '../../../src/web-server/usage/native-quota-collector';
|
|
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
|
import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials';
|
|
|
|
// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a
|
|
// breaker-test fetch is not blocked by the prior 429's per-call cooldown.
|
|
const MAX_COOLDOWN_JUMP = 61_000;
|
|
|
|
function maxCreds(): ClaudeNativeCredentials {
|
|
return { claudeAiOauth: { accessToken: 'native-tok', subscriptionType: 'max' } };
|
|
}
|
|
|
|
function successQuota(): ClaudeQuotaResult {
|
|
return {
|
|
success: true,
|
|
windows: [],
|
|
coreUsage: {
|
|
fiveHour: {
|
|
rateLimitType: 'five_hour',
|
|
label: 'Session limit',
|
|
remainingPercent: 42,
|
|
resetAt: '2026-06-09T20:00:00.000Z',
|
|
status: 'allowed',
|
|
},
|
|
weekly: {
|
|
rateLimitType: 'seven_day',
|
|
label: 'Weekly limit',
|
|
remainingPercent: 70,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
status: 'allowed',
|
|
},
|
|
},
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
};
|
|
}
|
|
|
|
/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */
|
|
function maxQuotaWithSplits(): ClaudeQuotaResult {
|
|
const base = successQuota();
|
|
return {
|
|
...base,
|
|
windows: [
|
|
{
|
|
rateLimitType: 'seven_day_opus',
|
|
label: 'Opus weekly',
|
|
status: 'allowed',
|
|
utilization: 0.25,
|
|
usedPercent: 25,
|
|
remainingPercent: 75,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_sonnet',
|
|
label: 'Sonnet weekly',
|
|
status: 'allowed',
|
|
utilization: 0.6,
|
|
usedPercent: 60,
|
|
remainingPercent: 40,
|
|
resetAt: '2026-06-15T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function reauthQuota(): ClaudeQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
needsReauth: true,
|
|
error: 'Authentication required',
|
|
};
|
|
}
|
|
|
|
function rateLimitedQuota(retryAfter?: string): ClaudeQuotaResult {
|
|
return {
|
|
success: false,
|
|
windows: [],
|
|
coreUsage: { fiveHour: null, weekly: null },
|
|
lastUpdated: Date.now(),
|
|
accountId: 'claude-code',
|
|
httpStatus: 429,
|
|
retryable: true,
|
|
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
|
error: 'rate limited',
|
|
};
|
|
}
|
|
|
|
/** Build a deps object with a controllable clock + counted fetch. */
|
|
function makeDeps(
|
|
fetchImpl: (token: string) => Promise<ClaudeQuotaResult>,
|
|
clock: { now: number },
|
|
credsImpl: () => ClaudeNativeCredentials | null = maxCreds
|
|
): NativeQuotaDeps & { fetchCount: () => number } {
|
|
let count = 0;
|
|
return {
|
|
readCredentials: credsImpl,
|
|
fetchClaudeQuota: async (token: string) => {
|
|
count += 1;
|
|
return fetchImpl(token);
|
|
},
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
sleep: async () => {},
|
|
fetchCount: () => count,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetNativeQuotaState();
|
|
});
|
|
|
|
describe('Claude native row mapping', () => {
|
|
it('maps a successful fetch into a claude-code ok row with min remaining', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row).toBeDefined();
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
// min(42, 70)
|
|
expect(row?.quota_percentage).toBe(42);
|
|
expect(row?.next_reset).toBe('2026-06-09T20:00:00.000Z');
|
|
expect(row?.tier).toBe('max');
|
|
expect(row?.displayName).toBe('Claude Code');
|
|
expect(row?.account_id).toBe('claude-code');
|
|
expect(row?.needsReauth).toBe(false);
|
|
});
|
|
|
|
it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
|
|
expect(row?.quotaWindows).toHaveLength(2);
|
|
const five = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
|
expect(five?.label).toBe('5h');
|
|
expect(five?.remainingPercent).toBe(42);
|
|
expect(five?.usedPercent).toBe(58); // 100 - 42
|
|
expect(five?.windowMinutes).toBe(300);
|
|
expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z');
|
|
|
|
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
|
expect(week?.label).toBe('week');
|
|
expect(week?.remainingPercent).toBe(70);
|
|
expect(week?.usedPercent).toBe(30);
|
|
expect(week?.windowMinutes).toBe(10080);
|
|
|
|
// Max-only splits absent here.
|
|
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined();
|
|
expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined();
|
|
});
|
|
|
|
it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => maxQuotaWithSplits(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
|
|
// five_hour, seven_day, seven_day_opus, seven_day_sonnet
|
|
expect(row?.quotaWindows).toHaveLength(4);
|
|
const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus');
|
|
expect(opus?.label).toBe('Opus · week');
|
|
expect(opus?.usedPercent).toBe(25);
|
|
expect(opus?.remainingPercent).toBe(75);
|
|
expect(opus?.windowMinutes).toBe(10080);
|
|
|
|
const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet');
|
|
expect(sonnet?.label).toBe('Sonnet · week');
|
|
expect(sonnet?.usedPercent).toBe(60);
|
|
expect(sonnet?.remainingPercent).toBe(40);
|
|
});
|
|
|
|
it('emits a reauth error row on 401/needsReauth', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => reauthQuota(), clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row?.quotaStatus).toBe('error');
|
|
expect(row?.health).toBe('error');
|
|
expect(row?.needsReauth).toBe(true);
|
|
});
|
|
|
|
it('omits the claude row when there is no token', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(
|
|
async () => successQuota(),
|
|
clock,
|
|
() => null
|
|
);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
});
|
|
|
|
it('omits the claude row for an unsupported (free) subscription without spending a call', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(
|
|
async () => successQuota(),
|
|
clock,
|
|
() => ({
|
|
claudeAiOauth: { accessToken: 'x', subscriptionType: 'free' },
|
|
})
|
|
);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
expect(deps.fetchCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('cache / TTL', () => {
|
|
it('serves cache within TTL and does NOT re-fetch', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Advance < 10 min: still cached.
|
|
clock.now += 5 * 60 * 1000;
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
expect(rows.find((r) => r.provider === 'claude-code')?.cached).toBe(true);
|
|
});
|
|
|
|
it('re-fetches after the TTL expires', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => successQuota(), clock);
|
|
|
|
await getNativeAccountRows(deps);
|
|
clock.now += 11 * 60 * 1000; // past 10-min TTL
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('in-flight coalescing', () => {
|
|
it('shares ONE fetch across concurrent callers past TTL', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let resolveFetch: (q: ClaudeQuotaResult) => void = () => {};
|
|
const gate = new Promise<ClaudeQuotaResult>((resolve) => {
|
|
resolveFetch = resolve;
|
|
});
|
|
const deps = makeDeps(async () => gate, clock);
|
|
|
|
const p1 = getNativeAccountRows(deps);
|
|
const p2 = getNativeAccountRows(deps);
|
|
resolveFetch(successQuota());
|
|
await Promise.all([p1, p2]);
|
|
|
|
expect(deps.fetchCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Retry-After + backoff + circuit breaker', () => {
|
|
it('honors Retry-After: no fetch until the cooldown elapses', async () => {
|
|
const t0 = 1_000_000;
|
|
const clock = { now: t0 };
|
|
const deps = makeDeps(async () => rateLimitedQuota('30'), clock);
|
|
|
|
await getNativeAccountRows(deps); // fetch #1 -> 429, cooldown = t0 + 30s
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Within the 30s Retry-After cooldown -> zero network even though there is
|
|
// no cached row yet.
|
|
clock.now = t0 + 10_000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(1);
|
|
|
|
// Past the 30s cooldown -> a fetch is allowed again.
|
|
clock.now = t0 + 31_000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(2);
|
|
});
|
|
|
|
it('trips the breaker after 3 consecutive 429s, then a success closes it', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let mode: 'fail' | 'ok' = 'fail';
|
|
const deps = makeDeps(
|
|
async () => (mode === 'fail' ? rateLimitedQuota() : successQuota()),
|
|
clock
|
|
);
|
|
|
|
// Three 429s; each separated past the per-call cooldown so they actually fetch.
|
|
for (let i = 0; i < 3; i++) {
|
|
await getNativeAccountRows(deps);
|
|
// jump past TTL and any backoff cooldown
|
|
clock.now += 11 * 60 * 1000 + MAX_COOLDOWN_JUMP;
|
|
}
|
|
expect(deps.fetchCount()).toBe(3);
|
|
|
|
// Breaker is open now -> zero network even past TTL.
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(3);
|
|
|
|
// Advance past the 15-min breaker cooldown; allow a success which closes it.
|
|
clock.now += 16 * 60 * 1000;
|
|
mode = 'ok';
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(4);
|
|
|
|
// After success, breaker closed: another fetch past TTL proceeds.
|
|
clock.now += 11 * 60 * 1000;
|
|
await getNativeAccountRows(deps);
|
|
expect(deps.fetchCount()).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('stale-on-fail', () => {
|
|
it('returns the last good row when a subsequent fetch rejects', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
let mode: 'ok' | 'throw' = 'ok';
|
|
const deps = makeDeps(async () => {
|
|
if (mode === 'throw') throw new Error('network down');
|
|
return successQuota();
|
|
}, clock);
|
|
|
|
await getNativeAccountRows(deps); // good row cached
|
|
mode = 'throw';
|
|
clock.now += 11 * 60 * 1000; // force re-fetch
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'claude-code');
|
|
expect(row).toBeDefined();
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.cached).toBe(true);
|
|
});
|
|
|
|
it('omits the row when the first-ever fetch fails (no prior cache)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps = makeDeps(async () => {
|
|
throw new Error('network down');
|
|
}, clock);
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'claude-code')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Codex path', () => {
|
|
it('maps a local Codex quota into a codex ok row', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null, // no claude row
|
|
getCodexQuota: async () => ({
|
|
quotaPercentage: 52,
|
|
nextReset: '2026-06-09T19:00:00.000Z',
|
|
tier: 'pro',
|
|
stale: false,
|
|
staleAsOf: null,
|
|
windows: [
|
|
{
|
|
key: 'five_hour',
|
|
label: '5h',
|
|
usedPercent: 19,
|
|
remainingPercent: 81,
|
|
resetAt: '2026-06-09T19:00:00.000Z',
|
|
windowMinutes: 300,
|
|
},
|
|
{
|
|
key: 'seven_day',
|
|
label: 'week',
|
|
usedPercent: 48,
|
|
remainingPercent: 52,
|
|
resetAt: '2026-06-14T00:00:00.000Z',
|
|
windowMinutes: 10080,
|
|
},
|
|
],
|
|
}),
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
expect(row?.quotaStatus).toBe('ok');
|
|
expect(row?.quota_percentage).toBe(52);
|
|
expect(row?.tier).toBe('pro');
|
|
expect(row?.health).toBe('ok');
|
|
expect(row?.quotaWindows).toHaveLength(2);
|
|
expect(row?.quotaWindows?.[0].windowMinutes).toBe(300);
|
|
expect(row?.staleAsOf).toBeUndefined();
|
|
});
|
|
|
|
it('flags health warning when the Codex source is stale', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null,
|
|
getCodexQuota: async () => ({
|
|
quotaPercentage: 10,
|
|
nextReset: null,
|
|
tier: null,
|
|
stale: true,
|
|
staleAsOf: '2026-06-09T13:30:00.000Z',
|
|
windows: [],
|
|
}),
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
const row = rows.find((r) => r.provider === 'codex');
|
|
expect(row?.health).toBe('warning');
|
|
// staleAsOf flows through so the bar can render the freshness footnote.
|
|
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
|
});
|
|
|
|
it('omits the codex row when there is no rate_limits (exec-mode)', async () => {
|
|
const clock = { now: 1_000_000 };
|
|
const deps: NativeQuotaDeps = {
|
|
readCredentials: () => null,
|
|
getCodexQuota: async () => null,
|
|
now: () => clock.now,
|
|
};
|
|
const rows = await getNativeAccountRows(deps);
|
|
expect(rows.find((r) => r.provider === 'codex')).toBeUndefined();
|
|
});
|
|
});
|