diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index e80b81ae..bd9b00ad 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -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; /** Load persisted CLIProxy usage details (from snapshot cache) */ loadCliproxyDetails: () => Promise; - /** Run system health checks */ - runHealthChecks: () => Promise; + /** + * 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; } // ============================================================================ -// 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(p: Promise, ms: number): Promise { + 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(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(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(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, - overallHealth: 'ok' | 'warning' | 'error', /** Set of cost-keys that are shared by more than one account. Cost is unknowable for these. */ sharedCostKeys: ReadonlySet ): 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 = {}; - 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 = 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 => { - 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(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 => { + 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 => { + const fetchResult = await fetchAccountData(account, doForceRefresh, deps); + return buildRow(account, fetchResult, costByAccount, sharedCostKeys); + }) + ); + rows.push(...batchRows); + } + return rows; + })(); + + const deadline = new Promise((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 => { + 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; diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts new file mode 100644 index 00000000..80186906 --- /dev/null +++ b/src/web-server/usage/bar-analytics.ts @@ -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(); + 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(); + const modelAll = new Map(); + const bump = ( + map: Map, + 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(), + }; +} diff --git a/tests/unit/web-server/bar-analytics.test.ts b/tests/unit/web-server/bar-analytics.test.ts new file mode 100644 index 00000000..daedbcae --- /dev/null +++ b/tests/unit/web-server/bar-analytics.test.ts @@ -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 { + 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'); + }); +}); diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index f84fa0de..845510e2 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -43,14 +43,16 @@ async function getJson(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(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(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(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(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(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(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 = { - '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 = {}; 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) { 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 () => {