mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
669 lines
20 KiB
TypeScript
669 lines
20 KiB
TypeScript
/**
|
|
* Claude Quota Fetcher Unit Tests
|
|
*
|
|
* Covers policy limits parsing and auth/token edge cases.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {
|
|
buildClaudeQuotaWindows,
|
|
buildClaudeCoreUsageSummary,
|
|
fetchClaudeQuota,
|
|
fetchAllClaudeQuotas,
|
|
} from '../../../src/cliproxy/quota-fetcher-claude';
|
|
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
|
|
|
let tmpDir: string;
|
|
let originalCcsHome: string | undefined;
|
|
let originalFetch: typeof fetch;
|
|
|
|
function createClaudeAccount(
|
|
accountId: string,
|
|
tokenPayload: Record<string, unknown>,
|
|
tokenPrefix: 'claude' | 'anthropic' = 'claude'
|
|
): void {
|
|
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
|
const authDir = path.join(cliproxyDir, 'auth');
|
|
const sanitized = sanitizeEmail(accountId);
|
|
const tokenFile = `${tokenPrefix}-${sanitized}.json`;
|
|
|
|
fs.mkdirSync(authDir, { recursive: true });
|
|
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload, null, 2));
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'accounts.json'),
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
providers: {
|
|
claude: {
|
|
default: accountId,
|
|
accounts: {
|
|
[accountId]: {
|
|
email: accountId,
|
|
tokenFile,
|
|
createdAt: '2026-02-20T00:00:00.000Z',
|
|
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-quota-test-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
process.env.CCS_HOME = tmpDir;
|
|
originalFetch = global.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('Claude Quota Fetcher', () => {
|
|
describe('buildClaudeQuotaWindows', () => {
|
|
it('parses restrictions array payload', () => {
|
|
const windows = buildClaudeQuotaWindows({
|
|
restrictions: [
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
utilization: 0.4,
|
|
resetsAt: '2026-02-28T10:00:00Z',
|
|
status: 'allowed',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
utilization: 0.8,
|
|
resetsAt: '2026-03-06T10:00:00Z',
|
|
status: 'allowed_warning',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(windows).toHaveLength(2);
|
|
expect(windows[0].rateLimitType).toBe('five_hour');
|
|
expect(windows[0].remainingPercent).toBe(60);
|
|
expect(windows[1].rateLimitType).toBe('seven_day');
|
|
expect(windows[1].remainingPercent).toBe(20);
|
|
});
|
|
|
|
it('parses object-map restrictions payload', () => {
|
|
const windows = buildClaudeQuotaWindows({
|
|
restrictions: {
|
|
five_hour: {
|
|
utilization: 0.25,
|
|
resetsAt: '2026-02-28T10:00:00Z',
|
|
status: 'allowed',
|
|
},
|
|
seven_day_opus: {
|
|
utilization: 0.9,
|
|
resetsAt: '2026-03-06T10:00:00Z',
|
|
status: 'allowed_warning',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(windows.map((window) => window.rateLimitType)).toEqual(
|
|
expect.arrayContaining(['five_hour', 'seven_day_opus'])
|
|
);
|
|
});
|
|
|
|
it('clamps utilization ratio into 0..1', () => {
|
|
const windows = buildClaudeQuotaWindows({
|
|
restrictions: [
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
utilization: 150,
|
|
status: 'allowed',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
utilization: -25,
|
|
status: 'allowed',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(windows).toHaveLength(2);
|
|
expect(windows[0].utilization).toBe(1);
|
|
expect(windows[0].remainingPercent).toBe(0);
|
|
expect(windows[1].utilization).toBe(0);
|
|
expect(windows[1].remainingPercent).toBe(100);
|
|
});
|
|
|
|
it('parses direct single restriction payload shape', () => {
|
|
const windows = buildClaudeQuotaWindows({
|
|
rateLimitType: 'five_hour',
|
|
utilization: 0.6,
|
|
status: 'allowed',
|
|
resetsAt: '2026-02-28T10:00:00Z',
|
|
});
|
|
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0].rateLimitType).toBe('five_hour');
|
|
expect(windows[0].remainingPercent).toBe(40);
|
|
});
|
|
});
|
|
|
|
describe('buildClaudeCoreUsageSummary', () => {
|
|
it('selects most restrictive weekly window', () => {
|
|
const summary = buildClaudeCoreUsageSummary([
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
label: 'Session limit',
|
|
status: 'allowed',
|
|
utilization: 0.35,
|
|
usedPercent: 35,
|
|
remainingPercent: 65,
|
|
resetAt: '2026-02-28T10:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
label: 'Weekly limit',
|
|
status: 'allowed',
|
|
utilization: 0.45,
|
|
usedPercent: 45,
|
|
remainingPercent: 55,
|
|
resetAt: '2026-03-06T10:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_opus',
|
|
label: 'Opus limit',
|
|
status: 'allowed_warning',
|
|
utilization: 0.85,
|
|
usedPercent: 85,
|
|
remainingPercent: 15,
|
|
resetAt: '2026-03-06T12:00:00Z',
|
|
},
|
|
]);
|
|
|
|
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
|
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
|
|
expect(summary.weekly?.remainingPercent).toBe(15);
|
|
});
|
|
|
|
it('considers oauth/cowork weekly windows in core summary', () => {
|
|
const summary = buildClaudeCoreUsageSummary([
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
label: 'Session limit',
|
|
status: 'allowed',
|
|
utilization: 0.35,
|
|
usedPercent: 35,
|
|
remainingPercent: 65,
|
|
resetAt: '2026-02-28T10:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_oauth_apps',
|
|
label: 'OAuth apps limit',
|
|
status: 'allowed_warning',
|
|
utilization: 0.92,
|
|
usedPercent: 92,
|
|
remainingPercent: 8,
|
|
resetAt: '2026-03-06T10:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_cowork',
|
|
label: 'Cowork limit',
|
|
status: 'allowed',
|
|
utilization: 0.4,
|
|
usedPercent: 40,
|
|
remainingPercent: 60,
|
|
resetAt: '2026-03-06T12:00:00Z',
|
|
},
|
|
]);
|
|
|
|
expect(summary.fiveHour?.rateLimitType).toBe('five_hour');
|
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps');
|
|
expect(summary.weekly?.remainingPercent).toBe(8);
|
|
});
|
|
|
|
it('does not duplicate weekly window into fiveHour when only weekly exists', () => {
|
|
const summary = buildClaudeCoreUsageSummary([
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
label: 'Weekly limit',
|
|
status: 'allowed',
|
|
utilization: 0.45,
|
|
usedPercent: 45,
|
|
remainingPercent: 55,
|
|
resetAt: '2026-03-06T10:00:00Z',
|
|
},
|
|
]);
|
|
|
|
expect(summary.fiveHour).toBeNull();
|
|
expect(summary.weekly?.rateLimitType).toBe('seven_day');
|
|
});
|
|
|
|
it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => {
|
|
const summary = buildClaudeCoreUsageSummary([
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
label: 'Session limit',
|
|
status: 'allowed',
|
|
utilization: 0.2,
|
|
usedPercent: 20,
|
|
remainingPercent: 80,
|
|
resetAt: '2026-02-28T10:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_opus',
|
|
label: 'Opus limit',
|
|
status: 'allowed',
|
|
utilization: 0.6,
|
|
usedPercent: 60,
|
|
remainingPercent: 40,
|
|
resetAt: '2026-03-06T12:00:00Z',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day_sonnet',
|
|
label: 'Sonnet limit',
|
|
status: 'allowed',
|
|
utilization: 0.6,
|
|
usedPercent: 60,
|
|
remainingPercent: 40,
|
|
resetAt: '2026-03-06T10:00:00Z',
|
|
},
|
|
]);
|
|
|
|
expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet');
|
|
});
|
|
});
|
|
|
|
describe('fetchClaudeQuota', () => {
|
|
it('fetches and normalizes policy limits response', async () => {
|
|
createClaudeAccount('claude-main@example.com', {
|
|
access_token: 'claude-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock((url: string, options?: RequestInit) => {
|
|
expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits');
|
|
expect(options?.method).toBe('GET');
|
|
expect(options?.headers).toMatchObject({
|
|
Authorization: 'Bearer claude-token',
|
|
Accept: 'application/json',
|
|
});
|
|
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
restrictions: [
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
utilization: 0.5,
|
|
resetsAt: '2026-03-01T01:00:00Z',
|
|
status: 'allowed',
|
|
},
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
utilization: 0.75,
|
|
resetsAt: '2026-03-07T01:00:00Z',
|
|
status: 'allowed_warning',
|
|
},
|
|
],
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
)
|
|
);
|
|
}) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-main@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.accountId).toBe('claude-main@example.com');
|
|
expect(result.windows).toHaveLength(2);
|
|
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50);
|
|
expect(result.coreUsage?.weekly?.remainingPercent).toBe(25);
|
|
|
|
const all = await fetchAllClaudeQuotas();
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0].account).toBe('claude-main@example.com');
|
|
expect(all[0].quota.success).toBe(true);
|
|
});
|
|
|
|
it('returns needsReauth on 401 responses', async () => {
|
|
createClaudeAccount(
|
|
'claude-auth@example.com',
|
|
{
|
|
access_token: 'expired-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'anthropic',
|
|
},
|
|
'anthropic'
|
|
);
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-auth@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.needsReauth).toBe(true);
|
|
expect(result.error).toContain('Authentication');
|
|
});
|
|
|
|
it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => {
|
|
createClaudeAccount(
|
|
'claude-oauth-unsupported@example.com',
|
|
{
|
|
access_token: 'oauth-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
},
|
|
'claude'
|
|
);
|
|
|
|
global.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
type: 'error',
|
|
error: {
|
|
type: 'authentication_error',
|
|
message: 'OAuth authentication is currently not supported.',
|
|
},
|
|
}),
|
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-oauth-unsupported@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.needsReauth).toBeUndefined();
|
|
expect(result.windows).toHaveLength(0);
|
|
expect(result.coreUsage?.fiveHour).toBeNull();
|
|
expect(result.coreUsage?.weekly).toBeNull();
|
|
});
|
|
|
|
it('treats root-level OAuth-unsupported 401 message as policy-limits unavailable', async () => {
|
|
createClaudeAccount('claude-oauth-root-message@example.com', {
|
|
access_token: 'oauth-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
message: 'OAuth authentication is currently not supported.',
|
|
}),
|
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-oauth-root-message@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.needsReauth).toBeUndefined();
|
|
expect(result.windows).toHaveLength(0);
|
|
expect(result.coreUsage?.fiveHour).toBeNull();
|
|
expect(result.coreUsage?.weekly).toBeNull();
|
|
});
|
|
|
|
it('treats plain-text OAuth-unsupported 401 as policy-limits unavailable', async () => {
|
|
createClaudeAccount('claude-oauth-plaintext@example.com', {
|
|
access_token: 'oauth-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response('OAuth authentication is currently not supported.', { status: 401 })
|
|
)
|
|
) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.needsReauth).toBeUndefined();
|
|
expect(result.windows).toHaveLength(0);
|
|
expect(result.coreUsage?.fiveHour).toBeNull();
|
|
expect(result.coreUsage?.weekly).toBeNull();
|
|
});
|
|
|
|
it('keeps non-matching 401 payloads in the reauth path', async () => {
|
|
createClaudeAccount('claude-auth-other-401@example.com', {
|
|
access_token: 'oauth-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
type: 'authentication_error',
|
|
message: 'Token revoked.',
|
|
},
|
|
}),
|
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-auth-other-401@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.needsReauth).toBe(true);
|
|
expect(result.error).toContain('Authentication');
|
|
});
|
|
|
|
it('treats 404 policy limits responses as unavailable but successful', async () => {
|
|
createClaudeAccount('claude-policy-limits-404@example.com', {
|
|
access_token: 'oauth-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-policy-limits-404@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.needsReauth).toBeUndefined();
|
|
expect(result.windows).toHaveLength(0);
|
|
expect(result.coreUsage?.fiveHour).toBeNull();
|
|
expect(result.coreUsage?.weekly).toBeNull();
|
|
});
|
|
|
|
it('fails fast when auth file has no token', async () => {
|
|
createClaudeAccount('claude-missing@example.com', {
|
|
access_token: ' ',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 })));
|
|
global.fetch = fetchMock as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-missing@example.com');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Auth file not found');
|
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it('treats missing expiry as not expired', async () => {
|
|
createClaudeAccount('claude-no-expiry@example.com', {
|
|
access_token: 'no-expiry-token',
|
|
type: 'claude',
|
|
});
|
|
|
|
global.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ restrictions: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-no-expiry@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.windows).toHaveLength(0);
|
|
});
|
|
|
|
it('retries once on transient 500 then succeeds', async () => {
|
|
createClaudeAccount('claude-retry@example.com', {
|
|
access_token: 'retry-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
let attempt = 0;
|
|
global.fetch = mock(() => {
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
return Promise.resolve(new Response('', { status: 500 }));
|
|
}
|
|
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
restrictions: [
|
|
{
|
|
rateLimitType: 'five_hour',
|
|
utilization: 0.4,
|
|
resetsAt: '2026-03-01T01:00:00Z',
|
|
status: 'allowed',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
);
|
|
}) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-retry@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(attempt).toBe(2);
|
|
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
|
|
});
|
|
|
|
it('retries once after AbortError and succeeds', async () => {
|
|
createClaudeAccount('claude-timeout@example.com', {
|
|
access_token: 'timeout-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'claude',
|
|
});
|
|
|
|
let attempt = 0;
|
|
global.fetch = mock(() => {
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
return Promise.reject(abortError);
|
|
}
|
|
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
restrictions: [
|
|
{
|
|
rateLimitType: 'seven_day',
|
|
utilization: 0.3,
|
|
resetsAt: '2026-03-07T01:00:00Z',
|
|
status: 'allowed',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
);
|
|
}) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota('claude-timeout@example.com');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(attempt).toBe(2);
|
|
expect(result.coreUsage?.weekly?.remainingPercent).toBe(70);
|
|
});
|
|
|
|
it('falls back to alternate auth file when preferred file is invalid JSON', async () => {
|
|
const accountId = 'claude-fallback@example.com';
|
|
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
|
const authDir = path.join(cliproxyDir, 'auth');
|
|
const sanitized = sanitizeEmail(accountId);
|
|
|
|
fs.mkdirSync(authDir, { recursive: true });
|
|
fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid');
|
|
fs.writeFileSync(
|
|
path.join(authDir, `anthropic-${sanitized}.json`),
|
|
JSON.stringify(
|
|
{
|
|
access_token: 'valid-anthropic-token',
|
|
expired: '2099-01-01T00:00:00.000Z',
|
|
type: 'anthropic',
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(cliproxyDir, 'accounts.json'),
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
providers: {
|
|
claude: {
|
|
default: accountId,
|
|
accounts: {
|
|
[accountId]: {
|
|
email: accountId,
|
|
tokenFile: `anthropic-${sanitized}.json`,
|
|
createdAt: '2026-02-20T00:00:00.000Z',
|
|
lastUsedAt: '2026-02-20T00:00:00.000Z',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
global.fetch = mock((_url: string, options?: RequestInit) => {
|
|
expect(options?.headers).toMatchObject({
|
|
Authorization: 'Bearer valid-anthropic-token',
|
|
});
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify({ restrictions: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
}) as typeof fetch;
|
|
|
|
const result = await fetchClaudeQuota(accountId);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
});
|