Files

508 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n';
import { normalizeClaudeUsageSnapshot } from '@/services/api/claudeUsage';
import { apiCallApi, getApiCallErrorMessage, type ApiCallResult } from '@/services/api/apiCall';
import { apiClient } from '@/services/api/client';
import { CLAUDE_CONFIG, hasClaudeUsageData } from '@/features/quota/providers/claude/data';
import { ClaudeQuotaBody } from '@/features/quota/providers/claude/ClaudeQuotaBody';
import { buildTimelineLane } from '@/features/quota/quotaTimelineModel';
import { nextRecoveryMs } from '@/features/quota/resetSchedule';
import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types';
import {
captureQuotaCacheGeneration,
commitIfQuotaCacheCurrent,
useQuotaStore,
} from '@/stores/useQuotaStore';
import {
CLAUDE_PROFILE_URL,
CLAUDE_USAGE_URL,
formatQuotaResetTime,
getStatusFromError,
} from '@/utils/quota';
import { getQuotaCacheKey } from '@/utils/quota/identity';
import type { ClaudeQuotaState } from '@/types';
import fixture from './fixtures/claude-oauth-usage.json';
const translations = i18n.cloneInstance({ lng: 'en' });
const observedAt = '2026-10-10T00:00:00Z';
const file = { name: 'claude-fixture.json', type: 'claude', authIndex: 'synthetic-index' };
const classes = bindQuotaClasses(
Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, key])),
'claude-usage-test'
);
const result = (statusCode: number, body: unknown): ApiCallResult => ({
statusCode,
header: {},
body,
bodyText: JSON.stringify(body),
});
const snapshot = normalizeClaudeUsageSnapshot(fixture)!;
const quota: ClaudeQuotaState = {
status: 'success',
windows: [],
extraUsage: snapshot.extraUsage,
dollarWindows: snapshot.dollarWindows,
observedAt,
};
const render = (state: ClaudeQuotaState) =>
renderToStaticMarkup(
createElement(
I18nextProvider,
{ i18n: translations },
createElement(ClaudeQuotaBody, { quota: state, classes })
)
);
describe('Claude OAuth usage API normalization', () => {
test('preserves every extra-usage field and discovers independent dollar balances', () => {
const normalized = normalizeClaudeUsageSnapshot(fixture)!;
expect(normalized.fiveHour).toEqual({
utilization: 42.5,
resetsAt: fixture.five_hour.resets_at,
});
expect(normalized.sevenDay?.utilization).toBe(16);
expect(normalized.extraUsage).toEqual({
isEnabled: true,
monthlyLimit: 5000,
usedCredits: 1250,
utilization: 25,
currency: 'USD',
disabledReason: null,
userDisabled: false,
spendLimitReached: false,
});
expect(normalized.dollarWindows.map((balance) => balance.key)).toEqual([
'iguana_necktie',
'additional_credit_balance',
]);
expect(normalized.dollarWindows[0]).toMatchObject({
limitDollars: 100,
usedDollars: 5.043595,
remainingDollars: 94.956405,
resetsAt: '2026-11-01T00:00:00Z',
});
expect(normalized.dollarWindows[1]).toMatchObject({
usedDollars: 0,
remainingDollars: 20,
utilization: 0,
resetsAt: null,
});
expect(normalized).not.toHaveProperty('observedAt');
});
test('retains null, false, and zero without fabricating values', () => {
const normalized = normalizeClaudeUsageSnapshot({
five_hour: { utilization: null, resets_at: null },
seven_day: null,
extra_usage: {
is_enabled: false,
monthly_limit: 0,
used_credits: 0,
utilization: null,
currency: 'EUR',
disabled_reason: 'user_disabled',
user_disabled: true,
spend_limit_reached: false,
},
future_upstream_key: {
limit_dollars: null,
used_dollars: 0,
remaining_dollars: null,
utilization: null,
resets_at: null,
},
})!;
expect(normalized.fiveHour).toEqual({ utilization: null, resetsAt: null });
expect(normalized.sevenDay).toBeNull();
expect(normalized.extraUsage).toMatchObject({
isEnabled: false,
monthlyLimit: 0,
usedCredits: 0,
currency: 'EUR',
disabledReason: 'user_disabled',
userDisabled: true,
spendLimitReached: false,
});
expect(normalized.dollarWindows[0]).toEqual({
key: 'future_upstream_key',
limitDollars: null,
usedDollars: 0,
remainingDollars: null,
utilization: null,
resetsAt: null,
});
});
test('ignores incomplete balances and unrelated payloads', () => {
expect(normalizeClaudeUsageSnapshot({ error: 'failed' })).toBeNull();
expect(normalizeClaudeUsageSnapshot(null)).toBeNull();
expect(normalizeClaudeUsageSnapshot([])).toBeNull();
expect(
normalizeClaudeUsageSnapshot({ five_hour: null, partial: { used_dollars: 5 } })
).toMatchObject({ fiveHour: null, dollarWindows: [] });
});
test('keeps every complete top-level dollar window even when its key is dollar_windows', () => {
const normalized = normalizeClaudeUsageSnapshot({
...fixture,
dollar_windows: { ...fixture.additional_credit_balance, remaining_dollars: 15 },
})!;
expect(normalized.dollarWindows.map((balance) => balance.key)).toEqual([
'iguana_necktie',
'additional_credit_balance',
'dollar_windows',
]);
expect(normalized.dollarWindows[2].remainingDollars).toBe(15);
});
test('uses only the existing authenticated raw API-call response contract', async () => {
const body = { error: { message: 'usage unavailable' } };
const post = spyOn(apiClient, 'post').mockResolvedValue({
status_code: 429,
header: {},
body: JSON.stringify(body),
});
try {
const request = { authIndex: file.authIndex, method: 'GET', url: CLAUDE_USAGE_URL };
const response = await apiCallApi.request(request);
expect(post).toHaveBeenCalledWith('/requests/api-call', request, undefined);
expect(response).toEqual(result(429, body));
expect(getApiCallErrorMessage(response)).toBe('429 usage unavailable');
} finally {
post.mockRestore();
}
});
});
describe('Claude quota usage reads', () => {
const originalRequest = apiCallApi.request;
afterEach(() => {
apiCallApi.request = originalRequest;
});
test('uses existing quota reads and separates dollar balances from plan windows', async () => {
const requests: Parameters<typeof apiCallApi.request>[0][] = [];
apiCallApi.request = async (request) => {
requests.push(request);
return request.url === CLAUDE_USAGE_URL
? result(200, fixture)
: result(200, { account: { has_claude_max: true } });
};
const data = await CLAUDE_CONFIG.fetchQuota(file, translations.t);
expect(requests.map((request) => request.url)).toEqual([CLAUDE_USAGE_URL, CLAUDE_PROFILE_URL]);
expect(requests[0]).toMatchObject({
method: 'GET',
authIndex: file.authIndex,
header: {
Authorization: 'Bearer $TOKEN$',
Accept: 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
},
});
expect(data.windows.map((window) => window.id)).toEqual([
'five-hour',
'seven-day',
'seven-day-sonnet',
]);
expect(data.dollarWindows).toEqual(snapshot.dollarWindows);
expect(data.extraUsage?.usedCredits).toBe(1250);
expect(data.planType).toBe('plan_max');
expect(Number.isFinite(Date.parse(data.observedAt))).toBe(true);
expect(CLAUDE_CONFIG.buildSuccessState(data)).toMatchObject({
status: 'success',
stale: false,
});
});
test('keeps dollar-balance recovery after the fixture plan windows have expired', async () => {
apiCallApi.request = async (request) =>
request.url === CLAUDE_USAGE_URL ? result(200, fixture) : result(200, null);
const state = CLAUDE_CONFIG.buildSuccessState(
await CLAUDE_CONFIG.fetchQuota(file, translations.t)
);
expect(state.windows.map((window) => window.id)).not.toContain('cloud-session-credits');
expect(nextRecoveryMs('claude', state, Date.parse('2026-10-31T00:00:00Z'))).toBe(
Date.parse('2026-11-01T00:00:00Z')
);
});
test('rejects every upstream failure even when its body contains usage values', async () => {
for (const status of [0, 199, 302, 401, 403, 429, 500]) {
apiCallApi.request = async (request) =>
request.url === CLAUDE_USAGE_URL
? result(status, fixture)
: result(200, { account: { has_claude_max: true } });
try {
await CLAUDE_CONFIG.fetchQuota(file, translations.t);
throw new Error('A failed read must not reach the success notification path');
} catch (error) {
expect(getStatusFromError(error)).toBe(status);
}
}
});
test('does not fabricate a snapshot when a read fails without cached data', async () => {
apiCallApi.request = async () => result(401, { error: 'usage unavailable' });
await expect(CLAUDE_CONFIG.fetchQuota(file, translations.t)).rejects.toThrow(
'401 usage unavailable'
);
});
test('preserves legacy Fable-only payloads and partial credit pools', async () => {
for (const window of [
{ utilization: 41, resets_at: fixture.seven_day.resets_at },
{ utilization: 5, remaining_dollars: 95, resets_at: fixture.seven_day.resets_at },
]) {
apiCallApi.request = async (request) =>
request.url === CLAUDE_USAGE_URL
? result(200, { iguana_necktie: window })
: result(503, null);
const data = await CLAUDE_CONFIG.fetchQuota(file, translations.t);
expect(data.windows).toHaveLength(1);
expect(data.windows[0].id).toBe(
'remaining_dollars' in window ? 'cloud-session-credits' : 'seven-day-fable'
);
expect(data.dollarWindows).toEqual([]);
}
});
test('rejects malformed successful responses rather than erasing the last-good values', async () => {
for (const body of [null, '', 'not-json', [], { error: 'no usage' }]) {
apiCallApi.request = async () => result(200, body);
await expect(CLAUDE_CONFIG.fetchQuota(file, translations.t)).rejects.toThrow(
translations.t('claude_quota.empty_windows')
);
}
});
test.each(['upstream', 'management'] as const)(
'retains frontend last-good values after a rejected %s read and replaces them on recovery',
async (failure) => {
let failing = false;
const post = spyOn(apiClient, 'post').mockImplementation(async (_path, request) => {
const usage = (request as { url: string }).url === CLAUDE_USAGE_URL;
if (usage && failing && failure === 'management') {
throw Object.assign(new Error('request failed'), { status: 502 });
}
return {
status_code: usage && failing ? 429 : 200,
header: {},
body: JSON.stringify(
!usage
? { account: { has_claude_max: true } }
: failing
? { error: { message: 'usage unavailable' } }
: fixture
),
};
});
const store = useQuotaStore.getState();
const key = getQuotaCacheKey(file);
store.clearQuotaCache();
try {
const good = CLAUDE_CONFIG.buildSuccessState(
await CLAUDE_CONFIG.fetchQuota(file, translations.t)
);
store.setClaudeQuota({ [key]: good });
failing = true;
store.setClaudeQuota({ [key]: CLAUDE_CONFIG.buildLoadingState() });
expect(useQuotaStore.getState().claudeQuota[key].windows).toBe(good.windows);
let failureHandled = false;
try {
await CLAUDE_CONFIG.fetchQuota(file, translations.t);
} catch (error) {
failureHandled = true;
store.setClaudeQuota({
[key]: CLAUDE_CONFIG.buildErrorState(
(error as Error).message,
getStatusFromError(error)
),
});
}
expect(failureHandled).toBe(true);
const retained = useQuotaStore.getState().claudeQuota[key];
expect(retained.status).toBe('error');
expect(retained.errorStatus).toBe(failure === 'management' ? 502 : 429);
expect(retained.stale).toBe(true);
expect(retained.observedAt).toBe(good.observedAt);
expect(retained.windows).toBe(good.windows);
expect(retained.extraUsage).toBe(good.extraUsage);
expect(retained.dollarWindows).toBe(good.dollarWindows);
failing = false;
const recovered = CLAUDE_CONFIG.buildSuccessState(
await CLAUDE_CONFIG.fetchQuota(file, translations.t)
);
store.setClaudeQuota({ [key]: recovered });
expect(useQuotaStore.getState().claudeQuota[key]).toBe(recovered);
expect(recovered).toMatchObject({ status: 'success', stale: false });
expect(recovered.error).toBeUndefined();
} finally {
post.mockRestore();
store.clearQuotaCache();
}
}
);
test('stores raw API-call data once for cards, page counters, recovery sorting, and timeline', async () => {
const post = spyOn(apiClient, 'post').mockImplementation(async (_path, request) => ({
status_code: 200,
header: {},
body: JSON.stringify(
(request as { url: string }).url === CLAUDE_USAGE_URL
? fixture
: { account: { has_claude_max: true } }
),
}));
const store = useQuotaStore.getState();
store.clearQuotaCache();
try {
expect(CLAUDE_CONFIG.storeSelector(useQuotaStore.getState())[file.name]).toBeUndefined();
const data = await CLAUDE_CONFIG.fetchQuota(file, translations.t);
const state = CLAUDE_CONFIG.buildSuccessState(data);
store.setClaudeQuota({ [getQuotaCacheKey(file)]: state });
const stored = CLAUDE_CONFIG.storeSelector(useQuotaStore.getState())[file.name];
expect(stored).toBe(state);
expect(
Object.values(useQuotaStore.getState().claudeQuota).filter(
(entry) => entry.status === 'success'
)
).toHaveLength(1);
expect(nextRecoveryMs('claude', stored, Date.parse(observedAt))).toBe(
Date.parse('2026-10-15T09:00:00Z')
);
expect(
buildTimelineLane({
provider: 'claude',
name: file.name,
displayName: file.name,
quota: stored,
maxPeriodHours: 14 * 24,
}).anchorMs
).toBe(Date.parse('2026-10-15T09:00:00Z'));
expect(post).toHaveBeenCalledTimes(2);
store.setClaudeQuota((previous) => previous);
expect(CLAUDE_CONFIG.storeSelector(useQuotaStore.getState())[file.name]).toBe(state);
} finally {
post.mockRestore();
store.clearQuotaCache();
}
});
});
describe('Claude usage snapshot rendering and retention', () => {
beforeEach(() => {
useQuotaStore.getState().clearQuotaCache();
});
test('offers read-only refresh for every non-idle auth-file quota, including Claude', () => {
// This host imports SCSS modules, so cover the action wiring here and
// verify its interaction with the production build in the browser.
const source = readFileSync(
new URL('../src/features/authFiles/components/AuthFileQuotaSection.tsx', import.meta.url),
'utf8'
);
// Idle quota is refreshed via the clickable idle prompt; avoid a duplicate button.
expect(source).toContain("const showRefreshQuotaAction = quotaStatus !== 'idle';");
expect(source).toContain('{showRefreshQuotaAction && (');
expect(source).toContain('<div className={styles.quotaCardActions}>');
expect(source).toContain('onClick={() => void refreshQuotaForFile()}');
expect(source).toContain("disabled={!canRefreshQuota || quotaStatus === 'loading'}");
expect(source).toContain("loading={quotaStatus === 'loading'}");
expect(source).toContain("{t('auth_files.quota_refresh_single')}");
expect(source).toContain("if (quota?.status === 'loading' || resettingQuota) return;");
expect(source).toContain(
'const canRefreshQuota = !disableControls && !file.disabled && !resettingQuota;'
);
expect(source).toContain('{resetQuotaAction}');
expect(CLAUDE_CONFIG).not.toHaveProperty('resetQuota');
});
test('renders enabled extra usage in minor units and each balance in dollars', () => {
const markup = render(quota);
expect(markup).toContain('Extra Usage');
expect(markup).toContain('Enabled');
expect(markup).toContain('Spent $12.50 / Limit $50.00');
expect(markup).toContain('25% used');
expect(markup).toContain('iguana_necktie');
expect(markup).toContain('$94.96 remaining');
expect(markup).toContain('Spent $5.04 / Limit $100.00');
expect(markup).toContain(formatQuotaResetTime(fixture.iguana_necktie.resets_at));
expect(markup).toContain('additional_credit_balance');
expect(markup).toContain('$20.00 remaining');
expect(markup).toContain('Spent $0.00 / Limit $20.00');
expect(markup).toContain('Not provided');
expect(markup).not.toContain('No quota data available');
});
test('renders disabled extra usage, reasons, and zero amounts', () => {
const markup = render({
...quota,
extraUsage: {
...snapshot.extraUsage!,
isEnabled: false,
usedCredits: 0,
monthlyLimit: 0,
utilization: 0,
userDisabled: true,
spendLimitReached: true,
disabledReason: 'user_disabled',
},
});
expect(markup).toContain('Disabled');
expect(markup).toContain('Spent $0.00 / Limit $0.00');
expect(markup).toContain('Disabled by user');
expect(markup).toContain('Spending limit reached');
expect(markup).toContain('Reason: user_disabled');
});
test('shows the last observation time when a retained snapshot is stale', () => {
const markup = render({ ...quota, status: 'error', stale: true });
expect(markup).toContain('role="status"');
expect(markup).toContain('Showing last successful check:');
expect(markup).toContain(new Date(observedAt).toLocaleString('en'));
expect(markup).toContain('$94.96 remaining');
});
test('keeps the last good data during loading and errors, then recovers', () => {
const store = useQuotaStore.getState();
store.setClaudeQuota({ [file.name]: quota });
store.setClaudeQuota({ [file.name]: CLAUDE_CONFIG.buildLoadingState() });
let retained = useQuotaStore.getState().claudeQuota[file.name];
expect(retained.status).toBe('loading');
expect(retained.extraUsage).toEqual(quota.extraUsage);
expect(retained.dollarWindows).toEqual(quota.dollarWindows);
store.setClaudeQuota({ [file.name]: CLAUDE_CONFIG.buildErrorState('network failed', 502) });
retained = useQuotaStore.getState().claudeQuota[file.name];
expect(retained.status).toBe('error');
expect(retained.stale).toBe(true);
expect(retained.observedAt).toBe(observedAt);
expect(retained.dollarWindows).toEqual(quota.dollarWindows);
const recovered = { ...quota, observedAt: '2026-10-10T01:00:00Z', stale: false };
store.setClaudeQuota({ [file.name]: recovered });
expect(useQuotaStore.getState().claudeQuota[file.name]).toEqual(recovered);
});
test('does not retain balances across file replacement or session invalidation', () => {
const store = useQuotaStore.getState();
store.setClaudeQuota({ [file.name]: quota });
const pending = captureQuotaCacheGeneration(file.name);
store.clearQuotaCache([file.name]);
expect(
commitIfQuotaCacheCurrent(pending, () => store.setClaudeQuota({ [file.name]: quota }))
).toBe(false);
store.setClaudeQuota({ [file.name]: CLAUDE_CONFIG.buildErrorState('new account read failed') });
expect(hasClaudeUsageData(useQuotaStore.getState().claudeQuota[file.name])).toBe(false);
store.setClaudeQuota({ [file.name]: quota });
const previousSession = captureQuotaCacheGeneration(file.name);
store.clearQuotaCache();
expect(
commitIfQuotaCacheCurrent(previousSession, () => store.setClaudeQuota({ [file.name]: quota }))
).toBe(false);
expect(useQuotaStore.getState().claudeQuota).toEqual({});
});
});