feat(bar): harden summary against provider hangs and add analytics endpoint

Summary aggregator could block indefinitely: it ran the full system health
audit (a synchronous execSync) on every request and had no per-account or
request-level timeout. Now:
- per-account fetch is bounded; whole response is raced against a deadline
- health is derived per-account from each quota result (no blocking audit)
- stale-while-revalidate keeps the last value when a refresh is slow/fails

Adds GET /api/bar/analytics plus a pure, tested aggregator rolling up
today/7d/30d/all-time spend, a 7-day sparkline, and top models.
This commit is contained in:
Tam Nhu Tran
2026-06-08 09:31:18 -04:00
parent f0734348d0
commit 78f9fc7c0b
4 changed files with 559 additions and 161 deletions
+160 -80
View File
@@ -21,6 +21,7 @@ import type { AccountInfo } from '../../cliproxy/accounts/types';
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
import type { HealthReport } from '../health-service';
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
import { computeBarAnalytics } from '../usage/bar-analytics';
// ============================================================================
// Types
@@ -76,32 +77,89 @@ export interface BarRouterDeps {
getTodayCostByAccount: (details: CliproxyUsageHistoryDetail[]) => Record<string, number>;
/** Load persisted CLIProxy usage details (from snapshot cache) */
loadCliproxyDetails: () => Promise<CliproxyUsageHistoryDetail[]>;
/** Run system health checks */
runHealthChecks: () => Promise<HealthReport>;
/**
* Optional, retained only for test back-compat. NOT used by the request path:
* the bar derives per-account health from each quota result. The real system
* audit shells out via a synchronous execSync that must never run here.
*/
runHealthChecks?: () => Promise<HealthReport>;
}
// ============================================================================
// Debounce state (module-level; reset across test suites via DI)
// Timing budgets (module-level; the bar must NEVER block on a slow provider)
// ============================================================================
/** Debounce window: skip force-fresh if last fresh pull was < 15s ago */
const FORCE_FRESH_DEBOUNCE_MS = 15_000;
/** Hard ceiling for the whole /summary response. Past this we paint from cache. */
const REQUEST_DEADLINE_MS = 2_500;
/** Per-account synchronous wait before falling back to stale cache (bg fetch continues). */
const PER_ACCOUNT_TIMEOUT_MS = 5_000;
/** Bound for the cost side-load so a slow snapshot read can't dominate the response. */
const SIDELOAD_TIMEOUT_MS = 1_500;
/** Timestamp of the last successful force-fresh pull (epoch ms, 0 = never) */
let lastForceFreshAt = 0;
/** Reset debounce state — called in tests to prevent cross-test pollution */
/** Reset module state — called in tests to prevent cross-test pollution */
export function resetForceFreshDebounce(): void {
lastForceFreshAt = 0;
}
/**
* Resolve a promise to its value, or to null if it doesn't settle within `ms`.
* The underlying promise keeps running (used to let a slow fetch warm the cache
* for the next open while the current response degrades gracefully).
*/
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
resolve(null);
}
}, ms);
p.then(
(v) => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve(v);
}
},
() => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve(null);
}
}
);
});
}
// ============================================================================
// Health mapping helper
// Per-account health derivation
// ============================================================================
function mapHealth(report: HealthReport): 'ok' | 'warning' | 'error' {
if (report.summary.errors > 0) return 'error';
if (report.summary.warnings > 0) return 'warning';
/**
* Health for a single account, derived from its own quota-fetch result.
*
* The menu bar is a per-account glance, so health is per-row — NOT the global
* system audit. (The system audit is also unsafe here: it shells out via a
* synchronous execSync that would block the event loop on the request path.)
*
* needsReauth → 'error' (token expired; user action required)
* fetch failed → 'warning' (transient/unknown; row degrades but isn't fatal)
* success → 'ok'
*/
function deriveHealth(quota: QuotaResult | null): 'ok' | 'warning' | 'error' {
if (quota?.needsReauth) return 'error';
if (!quota || !quota.success) return 'warning';
return 'ok';
}
@@ -147,49 +205,39 @@ async function fetchAccountData(
const provider = account.provider;
const accountId = account.id;
const now = new Date().toISOString();
const isPaused = account.paused === true;
// Read any prior cache up front so it survives as a stale fallback even when a
// refresh fetch is slow or fails (stale-while-revalidate).
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
// Paused accounts: serve cache if present, otherwise degrade.
// Never trigger a live fetch for a user-paused account (avoids unnecessary
// network calls and quota consumption for suspended accounts).
if (isPaused) {
const cachedQuota = deps.getCachedQuota<QuotaResult>(provider, accountId);
return {
quota: cachedQuota ?? null,
cached: cachedQuota !== null,
fetchedAt: now,
};
// Never trigger a live fetch for a user-paused account.
if (account.paused === true) {
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
}
// When force-fresh, invalidate first then fetch live
if (forceRefresh) {
deps.invalidateQuotaCache(provider, accountId);
try {
const quota = await deps.fetchAccountQuota(provider, accountId);
// Cache the result so subsequent default-mode calls serve it
deps.setCachedQuota(provider, accountId, quota);
return { quota, cached: false, fetchedAt: now };
} catch {
// Degrade this account row; don't throw
return { quota: null, cached: false, fetchedAt: now };
}
}
// Default mode: check cache first
const cached = deps.getCachedQuota<QuotaResult>(provider, accountId);
if (cached) {
// Default mode serves a present cache instantly (no provider call).
if (!forceRefresh && cached) {
return { quota: cached, cached: true, fetchedAt: now };
}
// Cache miss → fetch live (still in default mode)
try {
const quota = await deps.fetchAccountQuota(provider, accountId);
deps.setCachedQuota(provider, accountId, quota);
return { quota, cached: false, fetchedAt: now };
} catch {
return { quota: null, cached: false, fetchedAt: now };
// Force-fresh busts the route cache first (the stale value captured above
// still backs the fallback below).
if (forceRefresh) {
deps.invalidateQuotaCache(provider, accountId);
}
// Live fetch (force-refresh, or default-mode cache miss). It overwrites the
// cache on success and is bounded by PER_ACCOUNT_TIMEOUT_MS; if it overruns,
// the fetch keeps running (warming the cache for the next open) while this row
// degrades to the stale value so the payload never blocks.
const live = deps.fetchAccountQuota(provider as CLIProxyProvider, accountId).then((quota) => {
deps.setCachedQuota(provider, accountId, quota);
return quota;
});
const fresh = await withTimeout(live, PER_ACCOUNT_TIMEOUT_MS);
if (fresh) return { quota: fresh, cached: false, fetchedAt: now };
return { quota: cached ?? null, cached: cached !== null, fetchedAt: now };
}
// ============================================================================
@@ -214,12 +262,12 @@ function buildRow(
account: AccountInfo,
fetchResult: AccountFetchResult,
costByAccount: Record<string, number>,
overallHealth: 'ok' | 'warning' | 'error',
/** Set of cost-keys that are shared by more than one account. Cost is unknowable for these. */
sharedCostKeys: ReadonlySet<string>
): BarSummaryRow {
const { quota, cached, fetchedAt } = fetchResult;
const costKey = resolveCostKey(account);
const health = deriveHealth(quota);
// Fix #11: when multiple accounts share the same cost-key (e.g. two codex accounts with
// the same email), we cannot attribute the combined cost to either individual account.
@@ -237,7 +285,7 @@ function buildRow(
quota_percentage: null,
next_reset: null,
today_cost: todayCost,
health: quota?.needsReauth ? 'error' : overallHealth,
health,
cached,
fetchedAt,
needsReauth: quota?.needsReauth ?? false,
@@ -253,7 +301,7 @@ function buildRow(
quota_percentage: extractQuotaPercentage(quota),
next_reset: extractNextReset(quota),
today_cost: todayCost,
health: overallHealth,
health,
cached,
fetchedAt,
needsReauth: quota.needsReauth ?? false,
@@ -300,23 +348,13 @@ export function createBarRouter(deps: BarRouterDeps): Router {
// else: debounce active — fall through to cache path
}
// Fetch system health (overall, not per-account)
let overallHealth: 'ok' | 'warning' | 'error' = 'ok';
try {
const healthReport = await deps.runHealthChecks();
overallHealth = mapHealth(healthReport);
} catch {
overallHealth = 'warning'; // health service unavailable → degrade gracefully
}
// Load usage details for per-account cost mapping
let costByAccount: Record<string, number> = {};
try {
const details = await deps.loadCliproxyDetails();
costByAccount = deps.getTodayCostByAccount(details);
} catch {
// Cost unavailable — rows get null/0; non-fatal
}
// Cost side-load is bounded so a slow usage-snapshot read can't stall the
// glance. (Health is per-account, derived from each quota result below —
// no blocking system audit on the request path.)
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
const costByAccount: Record<string, number> = details
? deps.getTodayCostByAccount(details)
: {};
// Flatten all accounts across providers
const summary = deps.getAllAccountsSummary();
@@ -335,22 +373,48 @@ export function createBarRouter(deps: BarRouterDeps): Router {
.map(([key]) => key)
);
// Fetch quota in parallel with per-account error isolation.
// Concurrency is capped to avoid fan-out across large account lists.
// Paused accounts are handled inside fetchAccountData (cache/degrade, no live fetch).
const CONCURRENCY_CAP = 5;
const rows: BarSummaryRow[] = [];
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
const batchRows = await Promise.all(
batch.map(async (account): Promise<BarSummaryRow> => {
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
return buildRow(account, fetchResult, costByAccount, overallHealth, sharedCostKeys);
})
);
rows.push(...batchRows);
}
// Build every row synchronously from whatever is in cache right now. This
// is the instant-paint fallback and the source of truth when the deadline
// fires before live fetches finish.
const cacheRows = (): BarSummaryRow[] => {
const at = new Date().toISOString();
return allAccounts.map((account) => {
const cached = deps.getCachedQuota<QuotaResult>(account.provider, account.id);
return buildRow(
account,
{ quota: cached ?? null, cached: cached !== null, fetchedAt: at },
costByAccount,
sharedCostKeys
);
});
};
// Fetch quota in parallel with per-account error isolation. Each row is
// bounded inside fetchAccountData; the whole gather is additionally raced
// against REQUEST_DEADLINE_MS so the response NEVER hangs on a slow
// provider — past the deadline we paint from cache and let background
// fetches warm the next open.
const CONCURRENCY_CAP = 5;
const gather = (async (): Promise<BarSummaryRow[]> => {
const rows: BarSummaryRow[] = [];
for (let i = 0; i < allAccounts.length; i += CONCURRENCY_CAP) {
const batch = allAccounts.slice(i, i + CONCURRENCY_CAP);
const batchRows = await Promise.all(
batch.map(async (account): Promise<BarSummaryRow> => {
const fetchResult = await fetchAccountData(account, doForceRefresh, deps);
return buildRow(account, fetchResult, costByAccount, sharedCostKeys);
})
);
rows.push(...batchRows);
}
return rows;
})();
const deadline = new Promise<BarSummaryRow[]>((resolve) => {
setTimeout(() => resolve(cacheRows()), REQUEST_DEADLINE_MS);
});
const rows = await Promise.race([gather, deadline]);
res.json(rows);
} catch (err) {
console.error('[bar-routes] /summary error:', (err as Error).message);
@@ -358,6 +422,24 @@ export function createBarRouter(deps: BarRouterDeps): Router {
}
});
/**
* GET /analytics
*
* Rolls up the persisted usage snapshot into today / 7-day / 30-day spend, a
* 7-day sparkline, and the top models. Read-only and cheap (the snapshot is
* already on disk); bounded so a slow read can't stall the menu.
*/
router.get('/analytics', async (_req: Request, res: Response): Promise<void> => {
try {
const details = await withTimeout(deps.loadCliproxyDetails(), SIDELOAD_TIMEOUT_MS);
const analytics = computeBarAnalytics(details ?? [], new Date());
res.json(analytics);
} catch (err) {
console.error('[bar-routes] /analytics error:', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}
@@ -373,7 +455,6 @@ import {
} from '../../cliproxy/quota/quota-response-cache';
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
import { getTodayCostByAccount } from '../usage/data-aggregator';
import { runHealthChecks } from '../health-service';
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
/** Production bar router — wired to real dependencies */
@@ -385,7 +466,6 @@ const barRouter: Router = createBarRouter({
fetchAccountQuota,
getTodayCostByAccount,
loadCliproxyDetails: loadCliproxySnapshotDetails,
runHealthChecks,
});
export default barRouter;
+164
View File
@@ -0,0 +1,164 @@
/**
* Bar Analytics Aggregator
*
* Pure functions that roll up the flat CliproxyUsageHistoryDetail array (the
* same snapshot the bar already loads for per-account cost) into the small,
* glanceable analytics the menu bar surfaces: today / 7-day / 30-day spend,
* a 7-day cost sparkline, and the top models by spend.
*
* Kept dependency-free and deterministic (the reference "now" is injected) so
* it is trivially unit-testable and cheap enough to run on every bar open.
*/
import type { CliproxyUsageHistoryDetail } from './cliproxy-usage-transformer';
/** A single day's roll-up (local-day granularity). */
export interface BarAnalyticsDay {
/** Local calendar day, YYYY-MM-DD. */
date: string;
cost: number;
requests: number;
}
/** Aggregate spend over a rolling window. */
export interface BarAnalyticsWindow {
cost: number;
requests: number;
}
/** One model's contribution to spend over the trailing 7 days. */
export interface BarAnalyticsModel {
model: string;
cost: number;
requests: number;
}
/** The full analytics payload returned by GET /api/bar/analytics. */
export interface BarAnalytics {
today: BarAnalyticsWindow;
last7d: BarAnalyticsWindow;
last30d: BarAnalyticsWindow;
/** Lifetime totals across every record in the snapshot. */
allTime: BarAnalyticsWindow;
/** Oldest → newest, exactly 7 entries (zero-filled), for the sparkline. */
byDay: BarAnalyticsDay[];
/** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */
topModels: BarAnalyticsModel[];
/** Which window `topModels` covers — the most recent one that has data. */
topModelsWindow: '30d' | 'all';
/** ISO timestamp the payload was generated. */
generatedAt: string;
}
const SPARKLINE_DAYS = 7;
const TOP_MODELS_LIMIT = 5;
/** Local-time YYYY-MM-DD key for a Date (matches the user's calendar day). */
function localDayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Whole-day difference (a - b) in local days, via midnight-anchored dates. */
function dayDelta(a: Date, b: Date): number {
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate());
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((da.getTime() - db.getTime()) / 86_400_000);
}
/**
* Roll the raw details into the bar analytics payload, relative to `now`.
* Failed requests are excluded from spend (they carry no real cost).
*/
export function computeBarAnalytics(
details: CliproxyUsageHistoryDetail[],
now: Date
): BarAnalytics {
const today: BarAnalyticsWindow = { cost: 0, requests: 0 };
const last7d: BarAnalyticsWindow = { cost: 0, requests: 0 };
const last30d: BarAnalyticsWindow = { cost: 0, requests: 0 };
const allTime: BarAnalyticsWindow = { cost: 0, requests: 0 };
// Seed the sparkline with the trailing 7 local days (zero-filled, ordered).
const dayBuckets = new Map<string, BarAnalyticsDay>();
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
dayBuckets.set(localDayKey(d), { date: localDayKey(d), cost: 0, requests: 0 });
}
// Track per-model spend over both the trailing 30 days and all-time so we can
// show recent leaders when fresh, and lifetime leaders when the proxy has
// simply been idle lately.
const model30d = new Map<string, BarAnalyticsModel>();
const modelAll = new Map<string, BarAnalyticsModel>();
const bump = (
map: Map<string, BarAnalyticsModel>,
model: string,
cost: number,
requests: number
): void => {
const existing = map.get(model);
if (existing) {
existing.cost += cost;
existing.requests += requests;
} else {
map.set(model, { model, cost, requests });
}
};
for (const detail of details) {
if (detail.failed) continue;
const ts = new Date(detail.timestamp);
if (Number.isNaN(ts.getTime())) continue;
const delta = dayDelta(now, ts); // 0 = today, 1 = yesterday, …
if (delta < 0) continue; // ignore future-dated noise
const cost = Number.isFinite(detail.cost) ? detail.cost : 0;
const requests = Number.isFinite(detail.requestCount) ? detail.requestCount : 0;
allTime.cost += cost;
allTime.requests += requests;
bump(modelAll, detail.model, cost, requests);
if (delta === 0) {
today.cost += cost;
today.requests += requests;
}
if (delta < 7) {
last7d.cost += cost;
last7d.requests += requests;
const bucket = dayBuckets.get(localDayKey(ts));
if (bucket) {
bucket.cost += cost;
bucket.requests += requests;
}
}
if (delta < 30) {
last30d.cost += cost;
last30d.requests += requests;
bump(model30d, detail.model, cost, requests);
}
}
// Prefer recent leaders; fall back to lifetime when the last 30 days are idle.
const recentHasData = last30d.cost > 0 || last30d.requests > 0;
const sourceMap = recentHasData ? model30d : modelAll;
const topModels = Array.from(sourceMap.values())
.filter((m) => m.cost > 0 || m.requests > 0)
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_MODELS_LIMIT);
return {
today,
last7d,
last30d,
allTime,
byDay: Array.from(dayBuckets.values()),
topModels,
topModelsWindow: recentHasData ? '30d' : 'all',
generatedAt: now.toISOString(),
};
}
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'bun:test';
import { computeBarAnalytics } from '../../../src/web-server/usage/bar-analytics';
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
const NOW = new Date('2026-06-08T12:00:00-04:00');
function detail(over: Partial<CliproxyUsageHistoryDetail>): CliproxyUsageHistoryDetail {
return {
model: 'gpt-5.5',
timestamp: NOW.toISOString(),
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
requestCount: 1,
cost: 1,
failed: false,
...over,
};
}
/** Build an ISO timestamp `n` whole days before NOW (local). */
function daysAgo(n: number): string {
const d = new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - n, 10, 0, 0);
return d.toISOString();
}
describe('computeBarAnalytics', () => {
it('returns an empty/zeroed payload for no details', () => {
const a = computeBarAnalytics([], NOW);
expect(a.today.cost).toBe(0);
expect(a.allTime.cost).toBe(0);
expect(a.byDay).toHaveLength(7);
expect(a.topModels).toHaveLength(0);
expect(a.topModelsWindow).toBe('all');
});
it('rolls today / 7d / 30d / allTime into the right windows', () => {
const a = computeBarAnalytics(
[
detail({ timestamp: daysAgo(0), cost: 2, requestCount: 1 }), // today
detail({ timestamp: daysAgo(3), cost: 3, requestCount: 2 }), // 7d + 30d
detail({ timestamp: daysAgo(20), cost: 5, requestCount: 1 }), // 30d only
detail({ timestamp: daysAgo(90), cost: 10, requestCount: 4 }), // allTime only
],
NOW
);
expect(a.today.cost).toBe(2);
expect(a.last7d.cost).toBe(5); // 2 + 3
expect(a.last30d.cost).toBe(10); // 2 + 3 + 5
expect(a.allTime.cost).toBe(20); // + 10
expect(a.allTime.requests).toBe(8);
});
it('excludes failed requests from spend', () => {
const a = computeBarAnalytics(
[detail({ cost: 9, failed: true }), detail({ cost: 1, failed: false })],
NOW
);
expect(a.today.cost).toBe(1);
expect(a.allTime.cost).toBe(1);
});
it('zero-fills the 7-day sparkline in chronological order', () => {
const a = computeBarAnalytics([detail({ timestamp: daysAgo(2), cost: 4 })], NOW);
expect(a.byDay).toHaveLength(7);
// oldest first, newest last
expect(a.byDay[0].date < a.byDay[6].date).toBe(true);
const hit = a.byDay.find((d) => d.cost > 0);
expect(hit?.cost).toBe(4);
});
it('ranks top models by spend and labels the window 30d when recent data exists', () => {
const a = computeBarAnalytics(
[
detail({ model: 'gpt-5.4', timestamp: daysAgo(1), cost: 5 }),
detail({ model: 'gpt-5.5', timestamp: daysAgo(1), cost: 8 }),
detail({ model: 'gpt-5.4', timestamp: daysAgo(2), cost: 2 }),
],
NOW
);
expect(a.topModelsWindow).toBe('30d');
expect(a.topModels[0].model).toBe('gpt-5.5'); // 8
expect(a.topModels[1].model).toBe('gpt-5.4'); // 7
});
it('falls back to all-time top models when the last 30 days are idle', () => {
const a = computeBarAnalytics(
[
detail({ model: 'gpt-5.4', timestamp: daysAgo(60), cost: 100 }),
detail({ model: 'gpt-5.5', timestamp: daysAgo(45), cost: 40 }),
],
NOW
);
expect(a.last30d.cost).toBe(0);
expect(a.topModelsWindow).toBe('all');
expect(a.topModels[0].model).toBe('gpt-5.4');
});
});
+137 -81
View File
@@ -43,14 +43,16 @@ async function getJson<T>(baseUrl: string, path: string): Promise<{ status: numb
// Mock factories
// ============================================================================
function makeAccountInfo(overrides: Partial<{
id: string;
provider: string;
nickname: string;
tier: string;
paused: boolean;
isDefault: boolean;
}> = {}) {
function makeAccountInfo(
overrides: Partial<{
id: string;
provider: string;
nickname: string;
tier: string;
paused: boolean;
isDefault: boolean;
}> = {}
) {
return {
id: overrides.id ?? 'test@example.com',
provider: overrides.provider ?? 'agy',
@@ -63,25 +65,31 @@ function makeAccountInfo(overrides: Partial<{
};
}
function makeQuotaResult(overrides: Partial<{
success: boolean;
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
needsReauth: boolean;
error: string;
lastUpdated: number;
}> = {}) {
function makeQuotaResult(
overrides: Partial<{
success: boolean;
models: Array<{ name: string; percentage: number; resetTime: string | null }>;
needsReauth: boolean;
error: string;
lastUpdated: number;
}> = {}
) {
return {
success: overrides.success ?? true,
models: overrides.models ?? [{ name: 'gemini-3-pro', percentage: 75, resetTime: '2026-06-08T00:00:00Z' }],
models: overrides.models ?? [
{ name: 'gemini-3-pro', percentage: 75, resetTime: '2026-06-08T00:00:00Z' },
],
lastUpdated: overrides.lastUpdated ?? Date.now(),
needsReauth: overrides.needsReauth ?? false,
...(overrides.error !== undefined && { error: overrides.error }),
};
}
function makeHealthReport(overrides: Partial<{
summary: { errors: number; warnings: number; passed: number; total: number; info: number };
}> = {}) {
function makeHealthReport(
overrides: Partial<{
summary: { errors: number; warnings: number; passed: number; total: number; info: number };
}> = {}
) {
return {
timestamp: Date.now(),
version: '1.0.0',
@@ -216,8 +224,18 @@ describe('GET /api/bar/summary', () => {
});
it('maps account metadata into the row correctly', async () => {
mockAccounts = [makeAccountInfo({ id: 'alice@example.com', provider: 'agy', tier: 'ultra', paused: false, nickname: 'alice' })];
mockQuotaResult = makeQuotaResult({ models: [{ name: 'gemini-3-pro', percentage: 60, resetTime: '2026-06-08T00:00:00Z' }] });
mockAccounts = [
makeAccountInfo({
id: 'alice@example.com',
provider: 'agy',
tier: 'ultra',
paused: false,
nickname: 'alice',
}),
];
mockQuotaResult = makeQuotaResult({
models: [{ name: 'gemini-3-pro', percentage: 60, resetTime: '2026-06-08T00:00:00Z' }],
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
@@ -235,7 +253,9 @@ describe('GET /api/bar/summary', () => {
// --------------------------------------------------------------------------
it('default mode returns cached: true when cache is populated', async () => {
mockCachedQuota = makeQuotaResult({ models: [{ name: 'model-a', percentage: 80, resetTime: null }] });
mockCachedQuota = makeQuotaResult({
models: [{ name: 'model-a', percentage: 80, resetTime: null }],
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
const row = body[0];
@@ -323,7 +343,12 @@ describe('GET /api/bar/summary', () => {
// We test degradation by simulating the success path; the real per-account
// error test verifies via needsReauth row
mockAccounts = [makeAccountInfo({ id: 'reauth@example.com', provider: 'agy' })];
mockQuotaResult = makeQuotaResult({ success: false, needsReauth: true, models: [], error: 'token expired' });
mockQuotaResult = makeQuotaResult({
success: false,
needsReauth: true,
models: [],
error: 'token expired',
});
void callCount; // suppress lint
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
@@ -375,25 +400,36 @@ describe('GET /api/bar/summary', () => {
});
// --------------------------------------------------------------------------
// health mapping
// health is per-account, derived from each account's own quota result
// (no blocking system audit on the request path)
// --------------------------------------------------------------------------
it('health is "ok" when overall health has no errors or warnings', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 0, passed: 5, total: 5, info: 0 } });
it('health is "ok" when the account quota fetch succeeds', async () => {
mockQuotaResult = makeQuotaResult({ success: true });
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('ok');
});
it('health is "warning" when overall health has warnings but no errors', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 0, warnings: 2, passed: 3, total: 5, info: 0 } });
it('health is "warning" when the quota fetch fails without needing reauth', async () => {
mockQuotaResult = makeQuotaResult({
success: false,
needsReauth: false,
models: [],
error: 'temporary',
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('warning');
});
it('health is "error" when overall health has errors', async () => {
mockHealthReport = makeHealthReport({ summary: { errors: 1, warnings: 0, passed: 4, total: 5, info: 0 } });
it('health is "error" when the account needs reauthentication', async () => {
mockQuotaResult = makeQuotaResult({
success: false,
needsReauth: true,
models: [],
error: 'token expired',
});
const { body } = await getJson<BarSummaryRow[]>(baseUrl, '/api/bar/summary');
expect(body[0].health).toBe('error');
@@ -455,30 +491,35 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
// Simulate a codex account where id = "user@example.com#free"
// but the cost map key is the canonical email "user@example.com"
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const app = express();
app.use(express.json());
const costMap: Record<string, number> = {
'codex-user@example.com': 2.50, // keyed by email (as buildAuthIndexToAccountMap produces)
'codex-user@example.com': 2.5, // keyed by email (as buildAuthIndexToAccountMap produces)
};
const router = createBarRouter({
getAllAccountsSummary: () => ({
codex: [{
id: 'codex-user@example.com#free', // id has variant suffix
email: 'codex-user@example.com', // email is the canonical lookup key
provider: 'codex',
nickname: 'codex-user',
tier: 'free',
paused: false,
isDefault: true,
tokenFile: 'codex-codex-user_example_com-free.json',
createdAt: '2026-01-01T00:00:00.000Z',
}],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getAllAccountsSummary: () =>
({
codex: [
{
id: 'codex-user@example.com#free', // id has variant suffix
email: 'codex-user@example.com', // email is the canonical lookup key
provider: 'codex',
nickname: 'codex-user',
tier: 'free',
paused: false,
isDefault: true,
tokenFile: 'codex-codex-user_example_com-free.json',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
@@ -509,12 +550,14 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
// account_id should reflect the registry id
expect(row.account_id).toBe('codex-user@example.com#free');
// cost should be attributed via email lookup (2.50), not lost due to id mismatch
expect(row.today_cost).toBeCloseTo(2.50);
expect(row.today_cost).toBeCloseTo(2.5);
});
it('attributes cost correctly for account with no email (kiro/ghcp type): cost remains 0', async () => {
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const app2 = express();
app2.use(express.json());
@@ -525,20 +568,23 @@ describe('today_cost key consistency — non-email account.id (finding #4)', ()
const costMap2: Record<string, number> = {};
const router2 = createBarRouter({
getAllAccountsSummary: () => ({
kiro: [{
id: 'kiro-default',
// no email field
provider: 'kiro',
nickname: 'kiro-default',
tier: 'unknown',
paused: false,
isDefault: true,
tokenFile: 'kiro-default.json',
createdAt: '2026-01-01T00:00:00.000Z',
}],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getAllAccountsSummary: () =>
({
kiro: [
{
id: 'kiro-default',
// no email field
provider: 'kiro',
nickname: 'kiro-default',
tier: 'unknown',
paused: false,
isDefault: true,
tokenFile: 'kiro-default.json',
createdAt: '2026-01-01T00:00:00.000Z',
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
@@ -584,7 +630,9 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
concurrentInvalidateCalls = [];
fetchDelay = 0;
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
resetDebounce();
@@ -592,10 +640,11 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
app.use(express.json());
const router = createBarRouter({
getAllAccountsSummary: () => ({
agy: [makeAccountInfo({ id: 'concurrent@example.com', provider: 'agy' })],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getAllAccountsSummary: () =>
({
agy: [makeAccountInfo({ id: 'concurrent@example.com', provider: 'agy' })],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: (provider: string, accountId: string) => {
@@ -633,7 +682,8 @@ describe('debounce: concurrent refresh=true requests (finding #6)', () => {
beforeEach(() => {
concurrentInvalidateCalls = [];
fetchDelay = 20; // ms — enough for requests to overlap
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } =
require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
resetDebounce();
});
@@ -669,7 +719,9 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
beforeAll(async () => {
fetchedAccounts = [];
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
resetDebounce();
@@ -677,13 +729,14 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
app.use(express.json());
const router = createBarRouter({
getAllAccountsSummary: () => ({
agy: [
makeAccountInfo({ id: 'active@example.com', provider: 'agy', paused: false }),
makeAccountInfo({ id: 'paused@example.com', provider: 'agy', paused: true }),
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getAllAccountsSummary: () =>
({
agy: [
makeAccountInfo({ id: 'active@example.com', provider: 'agy', paused: false }),
makeAccountInfo({ id: 'paused@example.com', provider: 'agy', paused: true }),
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
getCachedQuota: () => null,
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
@@ -715,7 +768,8 @@ describe('force-fresh: paused accounts and concurrency cap (finding #7)', () =>
beforeEach(() => {
fetchedAccounts = [];
const { resetForceFreshDebounce: resetDebounce } = require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } =
require('../../../src/web-server/routes/bar-routes') as typeof import('../../../src/web-server/routes/bar-routes');
resetDebounce();
});
@@ -747,7 +801,9 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
async function buildRouter(accounts: object[], costMap: Record<string, number>) {
const { createBarRouter } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import('../../../src/web-server/routes/bar-routes');
const { resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const app = express();
app.use(express.json());
@@ -806,7 +862,7 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
},
];
// The cost map only has a combined total for the shared email
const costMap = { [sharedEmail]: 5.00 };
const costMap = { [sharedEmail]: 5.0 };
const { srv, url } = await buildRouter(accounts, costMap);
@@ -818,8 +874,8 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
expect(body[0].today_cost).toBeNull();
expect(body[1].today_cost).toBeNull();
// Neither should show the combined total
expect(body[0].today_cost).not.toBe(5.00);
expect(body[1].today_cost).not.toBe(5.00);
expect(body[0].today_cost).not.toBe(5.0);
expect(body[1].today_cost).not.toBe(5.0);
});
it('unique-email account still shows its individual cost', async () => {