refactor: update Devin quota localization strings for clarity

refactor: simplify Devin quota API response handling and improve type safety

test: enhance Devin quota tests for better coverage and accuracy

feat: add integration tests for Devin quota management API

fix: ensure proper handling of missing identities and malformed requests in Devin quota fetcher

chore: clean up unused code and improve overall code structure in Devin quota module
This commit is contained in:
Supra4E8C
2026-09-14 11:44:48 +08:00
parent c987511401
commit 96c25117ee
13 changed files with 430 additions and 511 deletions
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
captureQuotaCacheGeneration,
@@ -14,7 +14,6 @@ import { Button } from '@/components/ui/Button';
import { IconRefreshCw } from '@/components/ui/icons';
import { bindQuotaClasses } from '@/features/quota/types';
import { QUOTA_ADAPTERS, type QuotaCardState } from '@/features/quota/providers';
import { getDevinQuotaSnapshotState } from '@/features/quota/providers/devin/data';
import styles from './AuthFileQuota.module.scss';
/** 认证文件卡片外衣:紧凑额度样式绑定成类型化契约(缺键在模块初始化即抛)。 */
@@ -53,15 +52,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
if (quotaType === 'xai') return state.xaiQuota[cacheKey] as QuotaCardState | undefined;
return assertNever(quotaType);
});
const sessionGeneration = useQuotaStore((state) => state.cacheGeneration);
const fileGeneration = useQuotaStore((state) => state.fileGenerations[file.name] ?? 0);
const snapshotGeneration = useMemo(() => captureQuotaCacheGeneration(file.name), [file]);
const snapshotIsCurrent =
sessionGeneration === snapshotGeneration.cacheGeneration &&
fileGeneration === (snapshotGeneration.fileGenerations[file.name] ?? 0);
const quota =
storedQuota ??
(quotaType === 'devin' && snapshotIsCurrent ? getDevinQuotaSnapshotState(file, t) : undefined);
const quota = storedQuota;
const updateQuotaState = useQuotaStore(
(state) => state[adapter.storeSetter] as unknown as QuotaMapUpdater
+3 -20
View File
@@ -19,8 +19,7 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useNow } from '@/hooks/useNow';
import { useRevealGroup } from '@/hooks/motion';
import { useAuthStore, useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, DevinQuotaState, ResolvedTheme } from '@/types';
import { isDevinFile } from '@/utils/quota';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { getQuotaCacheKey } from '@/utils/quota/identity';
import { ProviderTabs } from '@/features/authFiles/components/ProviderTabs';
import { QuotaHeader } from './components/QuotaHeader';
@@ -46,7 +45,6 @@ import {
import { nextRecoveryMs } from './resetSchedule';
import { QUOTA_ADAPTERS, getQuotaSetter, type QuotaCardState } from './providers';
import type { QuotaProviderType } from './providers/types';
import { getDevinQuotaSnapshotState } from './providers/devin/data';
import { useDevinQuotaAutoLoad } from './providers/devin/useDevinQuotaAutoLoad';
import { useQuotaActions } from './hooks/useQuotaActions';
import { useQuotaBatchLoader } from './hooks/useQuotaBatchLoader';
@@ -84,12 +82,9 @@ export function QuotaPage() {
const sessionGeneration = useQuotaStore((state) => state.cacheGeneration);
const [filesGeneration, setFilesGeneration] = useState<number | null>(null);
const [filesFileGenerations, setFilesFileGenerations] = useState<Record<string, number>>({});
const fileGenerations = useQuotaStore((state) => state.fileGenerations);
const listRequestRef = useRef(0);
const loadFiles = useCallback(async () => {
const requestId = ++listRequestRef.current;
const requestFileGenerations = useQuotaStore.getState().fileGenerations;
if (connectionStatus !== 'connected') {
setFiles([]);
setFilesGeneration(null);
@@ -106,7 +101,6 @@ export function QuotaPage() {
if (!isCurrent()) return;
setFiles(data?.files || []);
setFilesGeneration(sessionGeneration);
setFilesFileGenerations(requestFileGenerations);
} catch (err: unknown) {
if (!isCurrent()) return;
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
@@ -135,28 +129,17 @@ export function QuotaPage() {
const kimiQuota = useQuotaStore((state) => state.kimiQuota);
const xaiQuota = useQuotaStore((state) => state.xaiQuota);
const devinSnapshots = useMemo(() => {
const snapshots: Record<string, DevinQuotaState> = {};
if (filesGeneration !== sessionGeneration) return snapshots;
files.filter(isDevinFile).forEach((file) => {
if ((fileGenerations[file.name] ?? 0) !== (filesFileGenerations[file.name] ?? 0)) return;
const snapshot = getDevinQuotaSnapshotState(file, t);
if (snapshot) snapshots[getQuotaCacheKey(file)] = snapshot;
});
return snapshots;
}, [files, filesGeneration, filesFileGenerations, fileGenerations, sessionGeneration, t]);
const quotaByType = useMemo<Record<QuotaProviderType, Record<string, QuotaCardState>>>(
() =>
({
antigravity: antigravityQuota,
claude: claudeQuota,
codex: codexQuota,
devin: { ...devinSnapshots, ...devinQuota },
devin: devinQuota,
kimi: kimiQuota,
xai: xaiQuota,
}) as unknown as Record<QuotaProviderType, Record<string, QuotaCardState>>,
[antigravityQuota, claudeQuota, codexQuota, devinQuota, devinSnapshots, kimiQuota, xaiQuota]
[antigravityQuota, claudeQuota, codexQuota, devinQuota, kimiQuota, xaiQuota]
);
const getQuota = useCallback(
+3 -16
View File
@@ -1,15 +1,13 @@
import type { TFunction } from 'i18next';
import type { AuthFileItem, DevinQuotaData, DevinQuotaState } from '@/types';
import { authFilesApi } from '@/services/api/authFiles';
import { hasDevinQuotaObservation, readDevinQuotaSnapshot } from '@/services/api/devinQuota';
import type { DevinQuotaData, DevinQuotaState } from '@/types';
import { apiCallApi } from '@/services/api/apiCall';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { isDevinFile, isDisabledAuthFile } from '@/utils/quota';
import type { QuotaProviderData } from '../types';
import { createDevinQuotaFetcher, DevinQuotaError } from './requests';
const fetchSnapshot = createDevinQuotaFetcher({
refresh: ({ name, authIndex }) => authFilesApi.requestManualRefresh(name, authIndex),
list: (target) => authFilesApi.list(target),
request: (payload) => apiCallApi.request(payload),
generation: (name) => {
const state = useQuotaStore.getState();
return { session: state.cacheGeneration, file: state.fileGenerations[name] ?? 0 };
@@ -24,17 +22,6 @@ const withLabels = (quota: DevinQuotaData, t: TFunction): DevinQuotaData => ({
})),
});
/** Render a passive list observation immediately without declaring it freshly queried. */
export function getDevinQuotaSnapshotState(
file: AuthFileItem,
t: TFunction
): DevinQuotaState | undefined {
const quota = readDevinQuotaSnapshot(file);
return hasDevinQuotaObservation(quota)
? { status: 'success', ...withLabels(quota, t) }
: undefined;
}
const emptyData = (): DevinQuotaData => ({
windows: [],
observedAtMs: null,
+34 -42
View File
@@ -1,10 +1,14 @@
import type { AuthFileItem, DevinQuotaData } from '@/types';
import type { AuthFileLookup } from '@/services/api/authFiles';
import { hasDevinQuotaObservation, readDevinQuotaSnapshot } from '@/services/api/devinQuota';
import {
getApiCallErrorMessage,
type ApiCallRequest,
type ApiCallResult,
} from '@/services/api/apiCall';
import { createStatusError } from '@/utils/quota';
import { hasDevinQuotaObservation, readDevinQuotaResponse } from '@/services/api/devinQuota';
import { normalizeAuthIndex } from '@/utils/authIndex';
export type DevinQuotaErrorCode =
'missing_identity' | 'stale_request' | 'file_not_found' | 'empty_data' | 'refresh_unconfirmed';
export type DevinQuotaErrorCode = 'missing_identity' | 'stale_request' | 'empty_data';
export class DevinQuotaError extends Error {
constructor(public readonly code: DevinQuotaErrorCode) {
@@ -19,20 +23,17 @@ export interface DevinRequestGeneration {
}
interface DevinQuotaDependencies {
refresh: (target: AuthFileLookup) => Promise<void>;
list: (target: AuthFileLookup) => Promise<{ files: AuthFileItem[] }>;
request: (payload: ApiCallRequest) => Promise<ApiCallResult>;
generation: (name: string) => DevinRequestGeneration;
}
/**
* Share in-flight work between card/page/batch entry points. Limit upstream calls
* to three at once. Every queued job and the POST → GET boundary recheck both
* to three at once. Every queued job and upstream response recheck both
* session and file generations, so old jobs cannot run against a new connection.
*/
export function createDevinQuotaFetcher(deps: DevinQuotaDependencies) {
const inFlight = new Map<string, Promise<DevinQuotaData>>();
const lastObserved = new Map<string, { key: string; atMs: number }>();
let observationSession: number | undefined;
const pools = new Map<number, { queue: Array<() => void>; active: number }>();
const getPool = (session: number) => {
@@ -57,13 +58,7 @@ export function createDevinQuotaFetcher(deps: DevinQuotaDependencies) {
const name = file.name.trim();
const authIndex = normalizeAuthIndex(file.authIndex ?? file.auth_index);
if (!name || !authIndex) return Promise.reject(new DevinQuotaError('missing_identity'));
const target = { name, authIndex };
const generation = deps.generation(name);
if (observationSession !== generation.session) {
lastObserved.clear();
observationSession = generation.session;
}
const identityKey = JSON.stringify([name, authIndex]);
const key = JSON.stringify([generation.session, generation.file, name, authIndex]);
const existing = inFlight.get(key);
if (existing) return existing;
@@ -74,40 +69,37 @@ export function createDevinQuotaFetcher(deps: DevinQuotaDependencies) {
throw new DevinQuotaError('stale_request');
}
};
const latest = lastObserved.get(identityKey);
const previous = Math.max(
readDevinQuotaSnapshot(file).observedAtMs ?? 0,
latest?.key === key ? latest.atMs : 0
);
const pool = getPool(generation.session);
const request = new Promise<DevinQuotaData>((resolve, reject) => {
pool.queue.push(() => {
const execute = async () => {
assertCurrent();
await deps.refresh(target);
const response = await deps.request({
authIndex,
method: 'POST',
url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
header: {
'Content-Type': 'application/json',
'Connect-Protocol-Version': '1',
},
data: JSON.stringify({
metadata: {
ideName: 'chisel',
ideVersion: '3000.10.21',
apiKey: '$TOKEN$',
locale: 'en',
os: 'darwin',
extensionVersion: '3000.10.21',
clientName: 'chisel',
},
}),
});
assertCurrent();
const response = await deps.list(target);
assertCurrent();
const freshFile = response.files.find(
(entry) =>
entry.name === name &&
normalizeAuthIndex(entry.authIndex ?? entry.auth_index) === authIndex &&
String(entry.provider ?? entry.type)
.trim()
.toLowerCase() === 'devin'
);
if (!freshFile) throw new DevinQuotaError('file_not_found');
const quota = readDevinQuotaSnapshot(freshFile);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(response), response.statusCode);
}
const quota = readDevinQuotaResponse(response.body);
if (!hasDevinQuotaObservation(quota)) throw new DevinQuotaError('empty_data');
// issue #429 guarantees quota.signals, but its response contract does not
// guarantee quota.observed_at. Use freshness checks when both observations
// exist; otherwise the successful synchronous POST → GET is authoritative.
if (previous > 0 && quota.observedAtMs !== null && quota.observedAtMs <= previous) {
throw new DevinQuotaError('refresh_unconfirmed');
}
if (quota.observedAtMs !== null) {
lastObserved.set(identityKey, { key, atMs: quota.observedAtMs });
}
return quota;
};
const finish = () => {
+1 -3
View File
@@ -517,9 +517,7 @@
"observed_unknown": "Quota observation time unavailable",
"missing_identity": "The credential name or auth index is missing. Reload the credential list.",
"stale_request": "The connection or credential changed. Please retry.",
"file_not_found": "The refreshed Devin credential could not be found. Reload the credential list.",
"empty_data": "The backend returned no usable Devin quota observation.",
"refresh_unconfirmed": "No newer quota observation was returned. Check the credential and retry.",
"empty_data": "Devin returned no usable quota data.",
"loading": "Querying Devin quota…",
"idle": "Click to query Devin quota",
"load_failed": "Failed to query quota: {{message}}",
+1 -3
View File
@@ -520,9 +520,7 @@
"observed_unknown": "Время получения квоты неизвестно",
"missing_identity": "Отсутствует имя или индекс учётных данных. Обновите список файлов.",
"stale_request": "Подключение или учётные данные изменились. Повторите запрос.",
"file_not_found": "Обновлённые учётные данные Devin не найдены. Обновите список файлов.",
"empty_data": "Сервер не вернул доступных данных о квоте Devin.",
"refresh_unconfirmed": "Новые данные квоты не получены. Проверьте учётные данные и повторите запрос.",
"empty_data": "Devin не вернул доступных данных о квоте.",
"loading": "Запрос квоты Devin…",
"idle": "Нажмите для запроса квоты Devin",
"load_failed": "Не удалось запросить квоту: {{message}}",
+1 -3
View File
@@ -515,9 +515,7 @@
"observed_unknown": "暂无额度观测时间",
"missing_identity": "缺少凭据名称或认证索引,请重新加载凭据列表。",
"stale_request": "连接或凭据已变更,请重试。",
"file_not_found": "未找到刷新后的 Devin 凭据,请重新加载凭据列表。",
"empty_data": "后端未返回可用的 Devin 额度观测数据。",
"refresh_unconfirmed": "未取得更新的额度观测,请检查凭据后重试。",
"empty_data": "Devin 未返回可用的额度数据。",
"loading": "正在查询 Devin 额度…",
"idle": "点击查询 Devin 额度",
"load_failed": "额度查询失败:{{message}}",
+1 -3
View File
@@ -515,9 +515,7 @@
"observed_unknown": "暫無額度觀測時間",
"missing_identity": "缺少憑證名稱或驗證索引,請重新載入憑證清單。",
"stale_request": "連線或憑證已變更,請重試。",
"file_not_found": "找不到重新整理後的 Devin 憑證,請重新載入憑證清單。",
"empty_data": "後端未傳回可用的 Devin 額度觀測資料。",
"refresh_unconfirmed": "未取得更新的額度觀測,請檢查憑證後重試。",
"empty_data": "Devin 未傳回可用的額度資料。",
"loading": "正在查詢 Devin 額度…",
"idle": "點擊查詢 Devin 額度",
"load_failed": "額度查詢失敗:{{message}}",
+37 -20
View File
@@ -1,4 +1,4 @@
import type { AuthFileItem, DevinQuotaData, DevinQuotaWindow } from '@/types';
import type { DevinQuotaData, DevinQuotaWindow } from '@/types';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -9,40 +9,57 @@ const parsePercent = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
}
if (typeof value !== 'string') return null;
const text = value.trim();
if (!/^\d+(?:\.\d+)?\s*%?$/.test(text)) return null;
const percent = Number(text.replace(/\s*%$/, ''));
if (typeof value !== 'string' || !/^\d+(?:\.\d+)?$/.test(value.trim())) return null;
const percent = Number(value);
return Number.isFinite(percent) && percent >= 0 && percent <= 100 ? percent : null;
};
/** Backend uses RFC3339; reject empty values and Go's zero time. */
const parseInstant = (value: unknown): number | null => {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) return null;
const ms = Date.parse(value);
return Number.isFinite(ms) && ms > 0 ? ms : null;
};
/**
* v7.3.0 auth-files quota.signals is a string map. Read only this observation,
* never metadata, account, the list's observed_at, or scheduler cooldowns.
*/
export function readDevinQuotaSnapshot(file: AuthFileItem): DevinQuotaData {
const quota = asRecord(file.quota);
const signals = asRecord(quota.signals);
const parseUnixSeconds = (value: unknown): number | null => {
if (typeof value !== 'number' && typeof value !== 'string') return null;
if (typeof value === 'string' && !/^\d+$/.test(value.trim())) return null;
const seconds = Number(value);
const ms = seconds * 1000;
return Number.isSafeInteger(seconds) && ms > 0 && Number.isFinite(new Date(ms).getTime())
? ms
: null;
};
/** Normalize the live Connect-RPC response, never auth-file metadata or quota.signals. */
export function readDevinQuotaResponse(
payload: unknown,
observedAtMs = Date.now()
): DevinQuotaData {
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = null;
}
}
const status = asRecord(asRecord(asRecord(payload).userStatus).planStatus);
const planInfo = asRecord(status.planInfo);
const windows: DevinQuotaWindow[] = (['daily', 'weekly'] as const).map((id) => ({
id,
remainingPercent: parsePercent(signals[`${id}_quota_remaining_percent`]),
resetAtMs: parseInstant(signals[`${id}_quota_reset_at`]),
// Periods describe the named windows, not inferred reset instants.
remainingPercent: parsePercent(status[`${id}QuotaRemainingPercent`]),
resetAtMs: parseUnixSeconds(status[`${id}QuotaResetAtUnix`]),
periodHours: id === 'daily' ? 24 : 168,
}));
return {
windows,
observedAtMs: parseInstant(quota.observed_at),
plan: typeof signals.plan === 'string' ? signals.plan.trim() || null : null,
planStartMs: parseInstant(signals.plan_start),
planEndMs: parseInstant(signals.plan_end),
observedAtMs: windows.some(
(window) => window.remainingPercent !== null || window.resetAtMs !== null
)
? observedAtMs
: null,
plan: typeof planInfo.planName === 'string' ? planInfo.planName.trim() || null : null,
planStartMs: parseInstant(status.planStart),
planEndMs: parseInstant(status.planEnd),
};
}
+78 -113
View File
@@ -1,127 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { hasDevinQuotaObservation, readDevinQuotaSnapshot } from '@/services/api/devinQuota';
import type { AuthFileItem } from '@/types';
import { hasDevinQuotaObservation, readDevinQuotaResponse } from '@/services/api/devinQuota';
const fileWith = (quota: unknown, extra: Record<string, unknown> = {}): AuthFileItem => ({
name: 'devin.json',
type: 'devin',
quota,
...extra,
});
const payload = {
userStatus: {
name: 'developer',
email: 'user@example.test',
planStatus: {
planInfo: { planName: 'Pro', accountDisplayName: 'Example' },
planStart: '2026-09-11T00:42:44Z',
planEnd: '2026-10-11T00:42:44Z',
dailyQuotaRemainingPercent: 54,
weeklyQuotaRemainingPercent: 77,
dailyQuotaResetAtUnix: '1789372800',
weeklyQuotaResetAtUnix: '1789891200',
},
},
};
describe('Devin quota normalization', () => {
test('accepts numeric and string percentages, including both boundaries', () => {
const cases: Array<[unknown, unknown, number | null, number | null]> = [
[0, 100, 0, 100],
['0%', ' 100 % ', 0, 100],
['42.5', '7.25%', 42.5, 7.25],
[-1, 101, null, null],
['-1%', '100.01', null, null],
['', 'NaN', null, null],
[undefined, {}, null, null],
];
const fromStatus = (status: Record<string, unknown>) =>
readDevinQuotaResponse({ userStatus: { planStatus: status } }, 1234);
for (const [daily, weekly, expectedDaily, expectedWeekly] of cases) {
const result = readDevinQuotaSnapshot(
fileWith({
signals: {
daily_quota_remaining_percent: daily,
weekly_quota_remaining_percent: weekly,
},
})
);
expect(result.windows.map((window) => window.remainingPercent)).toEqual([
expectedDaily,
expectedWeekly,
]);
describe('Devin live quota normalization', () => {
test('maps the latest issue 429 response and converts Unix seconds to milliseconds', () => {
const result = readDevinQuotaResponse(payload, 1234);
expect(result).toEqual({
windows: [
{ id: 'daily', remainingPercent: 54, resetAtMs: 1789372800000, periodHours: 24 },
{ id: 'weekly', remainingPercent: 77, resetAtMs: 1789891200000, periodHours: 168 },
],
plan: 'Pro',
planStartMs: Date.parse('2026-09-11T00:42:44Z'),
planEndMs: Date.parse('2026-10-11T00:42:44Z'),
observedAtMs: 1234,
});
expect(readDevinQuotaResponse(JSON.stringify(payload), 1234)).toEqual(result);
expect(JSON.stringify(result)).not.toContain('user@example.test');
});
test('preserves zero, full, fractional and numeric-string percentages without inferring missing values', () => {
for (const value of [0, 100, 0.5, '54']) {
const result = fromStatus({ dailyQuotaRemainingPercent: value });
expect(result.windows[0].remainingPercent).toBe(Number(value));
expect(result.windows[1].remainingPercent).toBeNull();
expect(result.observedAtMs).toBe(1234);
}
});
test('normalizes daily and weekly reset observations independently', () => {
const dailyReset = '2026-05-01T10:00:00Z';
const weeklyReset = '2026-05-07T11:30:00Z';
const result = readDevinQuotaSnapshot(
fileWith({
observed_at: '2026-04-30T12:00:00Z',
signals: {
daily_quota_remaining_percent: '20',
daily_quota_reset_at: dailyReset,
weekly_quota_remaining_percent: '80',
weekly_quota_reset_at: weeklyReset,
},
})
test('rejects malformed percentages and reset timestamps instead of showing full quota', () => {
for (const value of [null, undefined, '', ' ', '54%', -1, 101, NaN, Infinity, true, {}]) {
expect(
fromStatus({ dailyQuotaRemainingPercent: value }).windows[0].remainingPercent
).toBeNull();
}
for (const value of [null, '', ' ', 0, -1, 1.5, Infinity, 'bad', true, 1e20]) {
expect(fromStatus({ dailyQuotaResetAtUnix: value }).windows[0].resetAtMs).toBeNull();
}
expect(fromStatus({ dailyQuotaResetAtUnix: 1789372800 }).windows[0].resetAtMs).toBe(
1789372800000
);
expect(result.windows).toEqual([
{
id: 'daily',
remainingPercent: 20,
resetAtMs: Date.parse(dailyReset),
periodHours: 24,
},
{
id: 'weekly',
remainingPercent: 80,
resetAtMs: Date.parse(weeklyReset),
periodHours: 168,
},
]);
});
test('turns missing, malformed, and zero-time observations into null', () => {
const result = readDevinQuotaSnapshot(
fileWith({
observed_at: '0001-01-01T00:00:00Z',
signals: {
daily_quota_reset_at: 'not-a-date',
weekly_quota_reset_at: 1770000000000,
plan: ' ',
plan_start: '',
plan_end: false,
},
})
);
test('ignores old quota signals, metadata and invalid envelopes', () => {
for (const value of [
null,
[],
'not json',
{},
{ userStatus: [] },
{ quota: { signals: { daily_quota_remaining_percent: '100%' } } },
{ metadata: { dailyQuotaRemainingPercent: 100 } },
]) {
const result = readDevinQuotaResponse(value);
expect(hasDevinQuotaObservation(result)).toBeFalse();
expect(result.observedAtMs).toBeNull();
}
});
expect(result).toEqual({
windows: [
{ id: 'daily', remainingPercent: null, resetAtMs: null, periodHours: 24 },
{ id: 'weekly', remainingPercent: null, resetAtMs: null, periodHours: 168 },
],
observedAtMs: null,
plan: null,
planStartMs: null,
planEndMs: null,
test('keeps partial resets and plan data while rejecting invalid plan times', () => {
const result = fromStatus({
weeklyQuotaResetAtUnix: '1789891200',
planInfo: { planName: ' Pro ' },
planStart: '0001-01-01T00:00:00Z',
planEnd: 'not a date',
});
expect(hasDevinQuotaObservation(result)).toBeFalse();
});
test('reads only quota.signals and quota.observed_at, never lookalike credential fields', () => {
const secret = 'secret-access-token';
const result = readDevinQuotaSnapshot(
fileWith(
{
observed_at: '2026-05-01T00:00:00Z',
signals: {},
cooldown: {
daily_quota_remaining_percent: 97,
daily_quota_reset_at: '2026-06-01T00:00:00Z',
},
metadata: { weekly_quota_remaining_percent: 96, token: secret },
account: { daily_quota_remaining_percent: 95, api_key: secret },
},
{
observed_at: '2027-01-01T00:00:00Z',
cooldown: { weekly_quota_remaining_percent: 94 },
metadata: { daily_quota_remaining_percent: 93, access_token: secret },
account: secret,
access_token: secret,
}
)
);
expect(result.windows.every((window) => window.remainingPercent === null)).toBeTrue();
expect(result.observedAtMs).toBe(Date.parse('2026-05-01T00:00:00Z'));
expect(hasDevinQuotaObservation(result)).toBeFalse();
expect(JSON.stringify(result)).not.toContain(secret);
expect(hasDevinQuotaObservation(result)).toBeTrue();
expect(result.plan).toBe('Pro');
expect(result.planStartMs).toBeNull();
expect(result.planEndMs).toBeNull();
expect(result.windows[0].resetAtMs).toBeNull();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, spyOn, test } from 'bun:test';
import i18n from '@/i18n';
import { DEVIN_CONFIG } from '@/features/quota/providers/devin/data';
import { apiClient } from '@/services/api/client';
import { authFilesApi } from '@/services/api/authFiles';
describe('Devin quota Management API integration', () => {
test('queries only api-call with a body token placeholder, not auth-file refresh or list', async () => {
const post = spyOn(apiClient, 'post').mockResolvedValue({
status_code: 200,
header: {},
body: JSON.stringify({
userStatus: {
planStatus: { dailyQuotaRemainingPercent: 54, weeklyQuotaRemainingPercent: 77 },
},
}),
});
const refresh = spyOn(authFilesApi, 'requestManualRefresh');
const list = spyOn(authFilesApi, 'list');
try {
const result = await DEVIN_CONFIG.fetchQuota(
{ name: 'live.json', type: 'devin', auth_index: ' 42 ' },
i18n.t
);
expect(post).toHaveBeenCalledTimes(1);
expect(post.mock.calls[0][0]).toBe('/api-call');
expect(post.mock.calls[0][1]).toMatchObject({
authIndex: '42',
method: 'POST',
header: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' },
});
expect(refresh).not.toHaveBeenCalled();
expect(list).not.toHaveBeenCalled();
expect(result.windows.map((window) => window.remainingPercent)).toEqual([54, 77]);
} finally {
post.mockRestore();
refresh.mockRestore();
list.mockRestore();
}
});
});
+210 -265
View File
@@ -4,6 +4,7 @@ import {
DevinQuotaError,
type DevinRequestGeneration,
} from '@/features/quota/providers/devin/requests';
import type { ApiCallRequest, ApiCallResult } from '@/services/api/apiCall';
import type { AuthFileItem, DevinQuotaData } from '@/types';
type Deferred<T> = {
@@ -22,28 +23,37 @@ const deferred = <T>(): Deferred<T> => {
return { promise, resolve, reject };
};
const observedAt = (second: number) => `2026-05-01T00:00:${String(second).padStart(2, '0')}Z`;
const devinFile = (
const file = (
name: string,
authIndex: string | number,
second: number,
authIndex: string | number | undefined,
extra: Record<string, unknown> = {}
): AuthFileItem => ({
name,
provider: 'devin',
authIndex,
quota: {
observed_at: observedAt(second),
signals: {
daily_quota_remaining_percent: '40%',
daily_quota_reset_at: '2026-05-02T00:00:00Z',
weekly_quota_remaining_percent: 75,
weekly_quota_reset_at: '2026-05-08T00:00:00Z',
plan: 'pro',
...extra,
});
const liveBody = (overrides: Record<string, unknown> = {}) => ({
userStatus: {
planStatus: {
dailyQuotaRemainingPercent: 40,
dailyQuotaResetAtUnix: '1777680000',
weeklyQuotaRemainingPercent: '75',
weeklyQuotaResetAtUnix: 1778198400,
planInfo: { planName: 'pro' },
planStart: '2026-05-01T00:00:00Z',
planEnd: '2026-06-01T00:00:00Z',
...overrides,
},
},
...extra,
});
const result = (statusCode = 200, body: unknown = liveBody()): ApiCallResult => ({
statusCode,
header: {},
bodyText: body === null ? '' : JSON.stringify(body),
body,
});
const expectCode = async (promise: Promise<unknown>, code: string) => {
@@ -58,344 +68,279 @@ const expectCode = async (promise: Promise<unknown>, code: string) => {
const stableGeneration = (): DevinRequestGeneration => ({ session: 1, file: 1 });
const successfulFetcher = (
request: (payload: ApiCallRequest) => Promise<ApiCallResult> = async () => result(),
generation: (name: string) => DevinRequestGeneration = stableGeneration
) => createDevinQuotaFetcher({ request, generation });
describe('createDevinQuotaFetcher', () => {
test('performs targeted POST then GET and returns only normalized quota data', async () => {
const calls: Array<{ operation: string; target: unknown }> = [];
const secret = 'raw-refresh-credential';
const fetchQuota = createDevinQuotaFetcher({
refresh: async (target) => {
calls.push({ operation: 'refresh', target });
// The credential-bearing response is deliberately unavailable at this boundary.
return undefined;
},
list: async (target) => {
calls.push({ operation: 'list', target });
return {
files: [
devinFile(' account.json ', 'wrong', 2),
devinFile('account.json', 7, 2, {
access_token: secret,
refresh_token: secret,
account: secret,
metadata: { api_key: secret },
}),
],
};
},
generation: stableGeneration,
test('sends the exact Connect RPC request and returns only normalized live values', async () => {
const requests: ApiCallRequest[] = [];
const secret = 'credential-that-must-not-leak';
const before = Date.now();
const fetchQuota = successfulFetcher(async (payload) => {
requests.push(payload);
return result();
});
const result = await fetchQuota(devinFile(' account.json ', ' 7 ', 1));
const quota = await fetchQuota(
file(' account.json ', ' 007 ', {
access_token: secret,
metadata: { apiKey: secret },
quota: {
signals: {
daily_quota_remaining_percent: 1,
plan: secret,
},
},
})
);
const after = Date.now();
expect(calls).toEqual([
{ operation: 'refresh', target: { name: 'account.json', authIndex: '7' } },
{ operation: 'list', target: { name: 'account.json', authIndex: '7' } },
]);
expect(result).toEqual<DevinQuotaData>({
expect(requests).toHaveLength(1);
expect(requests[0]).toEqual({
authIndex: '007',
method: 'POST',
url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
header: {
'Content-Type': 'application/json',
'Connect-Protocol-Version': '1',
},
data: JSON.stringify({
metadata: {
ideName: 'chisel',
ideVersion: '3000.10.21',
apiKey: '$TOKEN$',
locale: 'en',
os: 'darwin',
extensionVersion: '3000.10.21',
clientName: 'chisel',
},
}),
});
expect(requests[0]!.header).not.toHaveProperty('Authorization');
expect(requests[0]!.header).not.toHaveProperty('X-Api-Key');
expect(quota).toEqual<DevinQuotaData>({
windows: [
{
id: 'daily',
remainingPercent: 40,
resetAtMs: Date.parse('2026-05-02T00:00:00Z'),
resetAtMs: 1777680000 * 1000,
periodHours: 24,
},
{
id: 'weekly',
remainingPercent: 75,
resetAtMs: Date.parse('2026-05-08T00:00:00Z'),
resetAtMs: 1778198400 * 1000,
periodHours: 168,
},
],
observedAtMs: Date.parse(observedAt(2)),
observedAtMs: expect.any(Number) as unknown as number,
plan: 'pro',
planStartMs: null,
planEndMs: null,
planStartMs: Date.parse('2026-05-01T00:00:00Z'),
planEndMs: Date.parse('2026-06-01T00:00:00Z'),
});
expect(JSON.stringify(result)).not.toContain(secret);
expect(quota.observedAtMs).toBeGreaterThanOrEqual(before);
expect(quota.observedAtMs).toBeLessThanOrEqual(after);
expect(JSON.stringify(quota)).not.toContain(secret);
});
test('requires a newer observation on repeated refreshes even with an old list entry', async () => {
test('accepts the same live quota response on repeated refreshes', async () => {
let calls = 0;
let nextSecond = 2;
let session = 1;
const fetchQuota = createDevinQuotaFetcher({
refresh: async () => {
calls += 1;
},
list: async () => ({ files: [devinFile('account.json', '7', nextSecond)] }),
generation: () => ({ session, file: 0 }),
const fetchQuota = successfulFetcher(async () => {
calls += 1;
return result();
});
const original = devinFile('account.json', '7', 1);
await fetchQuota(original);
// Start again immediately after awaiting: completed work must no longer be deduplicated.
await expectCode(fetchQuota(original), 'refresh_unconfirmed');
const authFile = file('repeat.json', '7');
const first = await fetchQuota(authFile);
await Promise.resolve();
const second = await fetchQuota(authFile);
expect(first.windows).toEqual(second.windows);
expect(calls).toBe(2);
nextSecond = 3;
await fetchQuota(original);
session = 2;
nextSecond = 2;
await fetchQuota(original);
expect(calls).toBe(4);
});
test('tracks observation freshness separately for same-name auth identities', async () => {
const fetchQuota = createDevinQuotaFetcher({
refresh: async () => {},
list: async ({ authIndex }) => ({ files: [devinFile('shared.json', authIndex!, 2)] }),
generation: stableGeneration,
test('deduplicates an in-flight auth identity but not a different auth index', async () => {
const gates = [deferred<ApiCallResult>(), deferred<ApiCallResult>()];
const seenIndexes: string[] = [];
const fetchQuota = successfulFetcher((payload) => {
seenIndexes.push(payload.authIndex!);
return gates[seenIndexes.length - 1]!.promise;
});
const first = devinFile('shared.json', 'first', 1);
const second = devinFile('shared.json', 'second', 1);
await fetchQuota(first);
await fetchQuota(second);
await expectCode(fetchQuota(first), 'refresh_unconfirmed');
await expectCode(fetchQuota(second), 'refresh_unconfirmed');
const first = fetchQuota(file(' shared.json ', 3));
const duplicate = fetchQuota(file('shared.json', ' 3 '));
const otherIdentity = fetchQuota(file('shared.json', '4'));
expect(duplicate).toBe(first);
expect(otherIdentity).not.toBe(first);
expect(seenIndexes).toEqual(['3', '4']);
gates.forEach((gate) => gate.resolve(result()));
const [firstResult, duplicateResult] = await Promise.all([first, duplicate, otherIdentity]);
expect(firstResult).toBe(duplicateResult);
});
test('deduplicates the same target in the same generation', async () => {
const refreshGate = deferred<void>();
let refreshCalls = 0;
let listCalls = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: () => {
refreshCalls += 1;
return refreshGate.promise;
},
list: async () => {
listCalls += 1;
return { files: [devinFile('same.json', '3', 2)] };
},
generation: stableGeneration,
});
const file = devinFile('same.json', 3, 1);
const first = fetchQuota(file);
const second = fetchQuota({ ...file });
expect(second).toBe(first);
expect(refreshCalls).toBe(1);
refreshGate.resolve(undefined);
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBe(secondResult);
expect(listCalls).toBe(1);
});
test('limits upstream workflows to three concurrent targets', async () => {
const gates = Array.from({ length: 5 }, () => deferred<void>());
test('limits requests to three concurrent auth identities', async () => {
const gates = Array.from({ length: 5 }, () => deferred<ApiCallResult>());
let started = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: () => gates[started++]!.promise,
list: async ({ name, authIndex }) => ({ files: [devinFile(name, authIndex!, 2)] }),
generation: stableGeneration,
});
const fetchQuota = successfulFetcher(() => gates[started++]!.promise);
const requests = Array.from({ length: 5 }, (_, index) =>
fetchQuota(devinFile(`file-${index}.json`, String(index), 1))
fetchQuota(file(`file-${index}.json`, String(index)))
);
expect(started).toBe(3);
gates[0]!.resolve(undefined);
gates[0]!.resolve(result());
await new Promise((resolve) => setTimeout(resolve, 0));
expect(started).toBe(4);
gates[1]!.resolve(undefined);
gates[1]!.resolve(result());
await new Promise((resolve) => setTimeout(resolve, 0));
expect(started).toBe(5);
gates[2]!.resolve(undefined);
gates[3]!.resolve(undefined);
gates[4]!.resolve(undefined);
gates.slice(2).forEach((gate) => gate.resolve(result()));
await Promise.all(requests);
});
test('does not let requests from an old session block a new connection', async () => {
const oldGates = Array.from({ length: 3 }, () => deferred<void>());
test('does not let saturated old-session requests block a new session', async () => {
const oldGates = Array.from({ length: 3 }, () => deferred<ApiCallResult>());
const started: string[] = [];
let oldIndex = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: ({ name }) => {
started.push(name);
return name.startsWith('old-') ? oldGates[oldIndex++]!.promise : Promise.resolve();
const fetchQuota = successfulFetcher(
(payload) => {
const index = payload.authIndex!;
started.push(index);
return index.startsWith('old-') ? oldGates[oldIndex++]!.promise : Promise.resolve(result());
},
list: async ({ name, authIndex }) => ({ files: [devinFile(name, authIndex!, 2)] }),
generation: (name) => ({ session: name.startsWith('old-') ? 1 : 2, file: 0 }),
});
(name) => ({ session: name.startsWith('old-') ? 1 : 2, file: 0 })
);
const oldRequests = Array.from({ length: 3 }, (_, index) =>
fetchQuota(devinFile(`old-${index}.json`, String(index), 1))
fetchQuota(file(`old-${index}.json`, `old-${index}`))
);
const currentRequest = fetchQuota(devinFile('current.json', 'current', 1));
const currentRequest = fetchQuota(file('current.json', 'current'));
expect(started).toContain('current.json');
await expect(currentRequest).resolves.toMatchObject({
observedAtMs: Date.parse(observedAt(2)),
});
oldGates.forEach((gate) => gate.resolve(undefined));
expect(started).toContain('current');
await expect(currentRequest).resolves.toMatchObject({ plan: 'pro' });
oldGates.forEach((gate) => gate.resolve(result()));
await Promise.all(oldRequests);
});
test('rejects a queued request whose file generation changed before it started', async () => {
const blockers = Array.from({ length: 3 }, () => deferred<void>());
test('checks session and file generations before a queued request executes', async () => {
const blockers = Array.from({ length: 3 }, () => deferred<ApiCallResult>());
const generations: Record<string, DevinRequestGeneration> = {};
const refreshed: string[] = [];
const requested: string[] = [];
let blockerIndex = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: ({ name }) => {
refreshed.push(name);
return name.startsWith('block') ? blockers[blockerIndex++]!.promise : Promise.resolve();
const fetchQuota = successfulFetcher(
(payload) => {
requested.push(payload.authIndex!);
return payload.authIndex!.startsWith('block')
? blockers[blockerIndex++]!.promise
: Promise.resolve(result());
},
list: async ({ name, authIndex }) => ({ files: [devinFile(name, authIndex!, 2)] }),
generation: (name) => generations[name] ?? { session: 1, file: 1 },
});
(name) => generations[name] ?? { session: 1, file: 1 }
);
const active = Array.from({ length: 3 }, (_, index) =>
fetchQuota(devinFile(`block-${index}.json`, String(index), 1))
fetchQuota(file(`block-${index}.json`, `block-${index}`))
);
const queuedName = 'queued.json';
const queued = fetchQuota(devinFile(queuedName, '9', 1));
generations[queuedName] = { session: 1, file: 2 };
blockers[0]!.resolve(undefined);
const staleSession = fetchQuota(file('stale-session.json', 'stale-session'));
const staleFile = fetchQuota(file('stale-file.json', 'stale-file'));
generations['stale-session.json'] = { session: 2, file: 1 };
generations['stale-file.json'] = { session: 1, file: 2 };
await expectCode(queued, 'stale_request');
expect(refreshed).not.toContain(queuedName);
blockers[1]!.resolve(undefined);
blockers[2]!.resolve(undefined);
blockers[0]!.resolve(result());
blockers[1]!.resolve(result());
await expectCode(staleSession, 'stale_request');
await expectCode(staleFile, 'stale_request');
expect(requested).not.toContain('stale-session');
expect(requested).not.toContain('stale-file');
blockers[2]!.resolve(result());
await Promise.all(active);
});
test('checks session generation after POST and file generation after GET', async () => {
const postGate = deferred<void>();
const afterPost = { session: 4, file: 8 };
let listCalls = 0;
const firstFetcher = createDevinQuotaFetcher({
refresh: () => postGate.promise,
list: async () => {
listCalls += 1;
return { files: [devinFile('post.json', '1', 2)] };
},
generation: () => ({ ...afterPost }),
});
const afterPostRequest = firstFetcher(devinFile('post.json', '1', 1));
afterPost.session += 1;
postGate.resolve(undefined);
await expectCode(afterPostRequest, 'stale_request');
expect(listCalls).toBe(0);
test('checks session and file generations after the response', async () => {
for (const changed of ['session', 'file'] as const) {
const gate = deferred<ApiCallResult>();
const generation = { session: 4, file: 8 };
const fetchQuota = successfulFetcher(
() => gate.promise,
() => ({ ...generation })
);
const request = fetchQuota(file(`${changed}.json`, changed));
const getGate = deferred<{ files: AuthFileItem[] }>();
const afterGet = { session: 4, file: 8 };
const secondFetcher = createDevinQuotaFetcher({
refresh: async () => undefined,
list: () => getGate.promise,
generation: () => ({ ...afterGet }),
});
const afterGetRequest = secondFetcher(devinFile('get.json', '2', 1));
afterGet.file += 1;
getGate.resolve({ files: [devinFile('get.json', '2', 2)] });
await expectCode(afterGetRequest, 'stale_request');
generation[changed] += 1;
gate.resolve(result());
await expectCode(request, 'stale_request');
}
});
test('rejects missing identities and mismatched returned identity or provider', async () => {
let refreshCalls = 0;
const missingFetcher = createDevinQuotaFetcher({
refresh: async () => {
refreshCalls += 1;
},
list: async () => ({ files: [] }),
generation: stableGeneration,
test('rejects missing names and auth indexes before making a request', async () => {
let calls = 0;
const fetchQuota = successfulFetcher(async () => {
calls += 1;
return result();
});
await expectCode(missingFetcher(devinFile(' ', '1', 1)), 'missing_identity');
await expectCode(missingFetcher(devinFile('valid.json', ' ', 1)), 'missing_identity');
expect(refreshCalls).toBe(0);
const returnedFiles = [
devinFile('other.json', '1', 2),
devinFile('valid.json', '2', 2),
{ ...devinFile('valid.json', '1', 2), provider: 'codex' },
];
const mismatchFetcher = createDevinQuotaFetcher({
refresh: async () => undefined,
list: async () => ({ files: returnedFiles }),
generation: stableGeneration,
});
await expectCode(mismatchFetcher(devinFile('valid.json', '1', 1)), 'file_not_found');
await expectCode(fetchQuota(file(' ', '1')), 'missing_identity');
await expectCode(fetchQuota(file('valid.json', ' ')), 'missing_identity');
await expectCode(fetchQuota(file('valid.json', undefined)), 'missing_identity');
expect(calls).toBe(0);
});
test('rejects empty and stale timed observations but accepts issue 429 signals without time', async () => {
const responses: AuthFileItem[][] = [
[
{
name: 'empty.json',
provider: 'devin',
authIndex: '1',
quota: { observed_at: observedAt(2), signals: {} },
},
],
[devinFile('old.json', '2', 1)],
[
{
...devinFile('missing-time.json', '3', 2),
quota: {
signals: { daily_quota_remaining_percent: 50 },
},
},
],
];
let responseIndex = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: async () => undefined,
list: async () => ({ files: responses[responseIndex++]! }),
generation: stableGeneration,
});
test('rejects unsuccessful statuses with the upstream status and message', async () => {
const fetchQuota = successfulFetcher(async () =>
result(429, { error: { message: 'quota endpoint throttled' } })
);
await expectCode(fetchQuota(devinFile('empty.json', '1', 1)), 'empty_data');
await expectCode(fetchQuota(devinFile('old.json', '2', 1)), 'refresh_unconfirmed');
await expect(fetchQuota(devinFile('missing-time.json', '3', 1))).resolves.toMatchObject({
observedAtMs: null,
windows: [
{ id: 'daily', remainingPercent: 50 },
{ id: 'weekly', remainingPercent: null },
],
});
try {
await fetchQuota(file('status.json', '1'));
throw new Error('expected request to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('429 quota endpoint throttled');
expect((error as Error & { status?: number }).status).toBe(429);
}
});
test('propagates dependency failures and allows a failed target to retry', async () => {
const failure = new Error('refresh exploded');
test('rejects successful responses without live quota data', async () => {
const fetchQuota = successfulFetcher(async () => result(200, { userStatus: {} }));
await expectCode(fetchQuota(file('empty.json', '1')), 'empty_data');
});
test('propagates request failures and allows the identity to retry', async () => {
const failure = new Error('request exploded');
let attempts = 0;
const fetchQuota = createDevinQuotaFetcher({
refresh: async () => {
attempts += 1;
if (attempts === 1) throw failure;
},
list: async ({ name, authIndex }) => ({ files: [devinFile(name, authIndex!, 2)] }),
generation: stableGeneration,
const fetchQuota = successfulFetcher(async () => {
attempts += 1;
if (attempts === 1) throw failure;
return result();
});
const file = devinFile('retry.json', '1', 1);
const authFile = file('retry.json', '1');
await expect(fetchQuota(file)).rejects.toBe(failure);
await expect(fetchQuota(authFile)).rejects.toBe(failure);
await Promise.resolve();
await expect(fetchQuota(file)).resolves.toMatchObject({
observedAtMs: Date.parse(observedAt(2)),
});
await expect(fetchQuota(authFile)).resolves.toMatchObject({ plan: 'pro' });
expect(attempts).toBe(2);
});
test('ignores generation changes belonging to an unrelated file', async () => {
const gate = deferred<void>();
const gate = deferred<ApiCallResult>();
const generations: Record<string, DevinRequestGeneration> = {
'target.json': { session: 3, file: 4 },
'other.json': { session: 3, file: 7 },
};
const fetchQuota = createDevinQuotaFetcher({
refresh: () => gate.promise,
list: async ({ name, authIndex }) => ({ files: [devinFile(name, authIndex!, 2)] }),
generation: (name) => generations[name]!,
});
const fetchQuota = successfulFetcher(
() => gate.promise,
(name) => generations[name]!
);
const request = fetchQuota(devinFile('target.json', '5', 1));
const request = fetchQuota(file('target.json', '5'));
generations['other.json']!.file += 1;
gate.resolve(undefined);
gate.resolve(result());
await expect(request).resolves.toMatchObject({ observedAtMs: Date.parse(observedAt(2)) });
await expect(request).resolves.toMatchObject({ plan: 'pro' });
});
});
+18 -12
View File
@@ -2,8 +2,9 @@ import { afterEach, describe, expect, test } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import i18n from '@/i18n';
import type { AuthFileItem } from '@/types';
import { DEVIN_CONFIG, getDevinQuotaSnapshotState } from '@/features/quota/providers/devin/data';
import type { AuthFileItem, DevinQuotaState } from '@/types';
import { readDevinQuotaResponse } from '@/services/api/devinQuota';
import { DEVIN_CONFIG } from '@/features/quota/providers/devin/data';
import { DevinQuotaBody } from '@/features/quota/providers/devin/DevinQuotaBody';
import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types';
import { classifyQuotaFiles, buildTabCounts } from '@/features/quota/logic';
@@ -20,15 +21,17 @@ const file: AuthFileItem = {
name: 'devin-example.json',
provider: 'devin',
authIndex: 'example-index',
quota: {
observed_at: '2099-01-01T00:00:00Z',
signals: {
daily_quota_remaining_percent: '0%',
weekly_quota_remaining_percent: '80%',
daily_quota_reset_at: '2099-01-02T00:00:00Z',
weekly_quota_reset_at: '2099-01-08T00:00:00Z',
plan: 'Pro',
plan_end: '2099-01-03T00:00:00Z',
};
const observedAtMs = Date.parse('2099-01-01T00:00:00Z');
const livePayload = {
userStatus: {
planStatus: {
dailyQuotaRemainingPercent: 0,
weeklyQuotaRemainingPercent: 80,
dailyQuotaResetAtUnix: Date.parse('2099-01-02T00:00:00Z') / 1000,
weeklyQuotaResetAtUnix: Date.parse('2099-01-08T00:00:00Z') / 1000,
planInfo: { planName: 'Pro' },
planEnd: '2099-01-03T00:00:00Z',
},
},
};
@@ -36,7 +39,10 @@ const classes = bindQuotaClasses(
Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, key])),
'test'
);
const snapshot = () => getDevinQuotaSnapshotState(file, i18n.t)!;
const snapshot = (): DevinQuotaState => ({
status: 'success',
...readDevinQuotaResponse(livePayload, observedAtMs),
});
afterEach(() => useQuotaStore.getState().clearQuotaCache());