mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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,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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user