mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
feat(bar): live Codex usage sync with force refresh and spend-chart periods
Codex quota in the menu bar came only from frozen local session logs, so it
showed stale data ("older session") that no refresh could update. Fetch it live
from the same source the dashboard uses, under the existing Claude-style safety
controls (10-min TTL, in-flight coalescing, 429 backoff, circuit breaker,
serve-stale), falling back to local logs when offline or rate-limited. A success
with no usable 5h/weekly window keeps the local reading rather than caching a
contentless row. The footer refresh and a new inline button on the Codex card
force a live pull past the open debounce, and a forced /summary re-pulls native
rows while serving the last-known cached rows if the live pull overruns its
budget, so the Claude/Codex cards never blank mid-refresh.
The spend strip gains a Today / 7d / 30d selector (default 7d), a taller chart,
and bottom time-axis labels (local hours for today, weekday for 7d, dates for
30d). The analytics endpoint now exposes a 24-bucket hourly series for today,
converted from the pipeline's UTC hour keys to the user's local clock so the
intra-day chart matches the dashboard.
This commit is contained in:
1 parent
9977c49fab
commit
6ed5b2d62b
13 files changed
+1263
-53
No files matched your search
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
computeBarAnalytics,
|
||||
computeBarAnalyticsFromDaily,
|
||||
localDayKey,
|
||||
} from '../../../src/web-server/usage/bar-analytics';
|
||||
import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer';
|
||||
import type { DailyUsage, HourlyUsage } from '../../../src/web-server/usage/types';
|
||||
@@ -251,3 +252,130 @@ describe('computeBarAnalyticsFromDaily — monthToDate', () => {
|
||||
expect(a.monthToDate).toEqual({ cost: 0, requests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// byHour — 24-bucket intra-day spend chart for today
|
||||
// ============================================================================
|
||||
|
||||
describe('byHour — intra-day spend chart', () => {
|
||||
// NOW = 2026-06-08T12:00:00-04:00. byHour buckets are the user's LOCAL clock
|
||||
// hours; the pipeline's hour keys are UTC and get converted to local. These
|
||||
// tests build UTC keys FROM the desired local time so they pass regardless of
|
||||
// the test runner's timezone (the round-trip is machine-TZ-independent).
|
||||
const todayKey = localDayKey(NOW);
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/** A machine-local Date at `hour` o'clock on NOW's local calendar day (+dayOffset). */
|
||||
function localDayAt(hour: number, dayOffset = 0): Date {
|
||||
return new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() + dayOffset, hour, 0, 0);
|
||||
}
|
||||
|
||||
/** The UTC "YYYY-MM-DD HH:00" key that converts back to `local`'s clock hour. */
|
||||
function utcHourKeyForLocal(local: Date): string {
|
||||
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(
|
||||
local.getUTCDate()
|
||||
)} ${pad(local.getUTCHours())}:00`;
|
||||
}
|
||||
|
||||
it('computeBarAnalytics returns byHour as empty array (snapshot path has no hourly)', () => {
|
||||
const a = computeBarAnalytics([], NOW);
|
||||
expect(Array.isArray(a.byHour)).toBe(true);
|
||||
expect(a.byHour).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('computeBarAnalyticsFromDaily returns exactly 24 hour buckets', () => {
|
||||
const a = computeBarAnalyticsFromDaily([], [], NOW);
|
||||
expect(a.byHour).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('hour buckets are LOCAL, ordered 00:00 → 23:00', () => {
|
||||
const a = computeBarAnalyticsFromDaily([], [], NOW);
|
||||
expect(a.byHour[0].hour).toBe(`${todayKey} 00:00`);
|
||||
expect(a.byHour[23].hour).toBe(`${todayKey} 23:00`);
|
||||
for (let i = 1; i < 24; i++) {
|
||||
expect(a.byHour[i].hour > a.byHour[i - 1].hour).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('hours with no data are zero-filled', () => {
|
||||
const a = computeBarAnalyticsFromDaily([], [], NOW);
|
||||
for (const h of a.byHour) {
|
||||
expect(h.cost).toBe(0);
|
||||
expect(h.requests).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('a UTC hour key lands in the matching LOCAL hour bucket (not the raw UTC hour)', () => {
|
||||
// Activity at local 10:00 today, encoded as its UTC key.
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[],
|
||||
[
|
||||
hourly({ hour: utcHourKeyForLocal(localDayAt(10)), totalCost: 3.5, requestCount: 2 }),
|
||||
hourly({ hour: utcHourKeyForLocal(localDayAt(14)), totalCost: 1.0, requestCount: 1 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.byHour[10].hour).toBe(`${todayKey} 10:00`);
|
||||
expect(a.byHour[10].cost).toBeCloseTo(3.5);
|
||||
expect(a.byHour[10].requests).toBe(2);
|
||||
expect(a.byHour[14].cost).toBeCloseTo(1.0);
|
||||
expect(a.byHour[14].requests).toBe(1);
|
||||
});
|
||||
|
||||
it('late local-evening activity (next day in UTC) still lands in TODAY', () => {
|
||||
// local 23:00 today is the next calendar day in any UTC-negative zone; it
|
||||
// must NOT be dropped from today's chart (regression guard for the TZ bug).
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[],
|
||||
[hourly({ hour: utcHourKeyForLocal(localDayAt(23)), totalCost: 7, requestCount: 4 })],
|
||||
NOW
|
||||
);
|
||||
expect(a.byHour[23].cost).toBeCloseTo(7);
|
||||
expect(a.byHour[23].requests).toBe(4);
|
||||
// Every other hour stays zero.
|
||||
for (let i = 0; i < 23; i++) expect(a.byHour[i].cost).toBe(0);
|
||||
});
|
||||
|
||||
it('yesterday-local activity does NOT land in today buckets', () => {
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[],
|
||||
[hourly({ hour: utcHourKeyForLocal(localDayAt(10, -1)), totalCost: 99, requestCount: 10 })],
|
||||
NOW
|
||||
);
|
||||
for (const h of a.byHour) {
|
||||
expect(h.cost).toBe(0);
|
||||
expect(h.requests).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('accumulates multiple sources into the same local hour bucket', () => {
|
||||
const key = utcHourKeyForLocal(localDayAt(9));
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[],
|
||||
[
|
||||
hourly({ hour: key, source: 'custom-parser', totalCost: 2.0, requestCount: 3 }),
|
||||
hourly({ hour: key, source: 'codex-native', totalCost: 1.5, requestCount: 1 }),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(a.byHour[9].cost).toBeCloseTo(3.5);
|
||||
expect(a.byHour[9].requests).toBe(4);
|
||||
});
|
||||
|
||||
it('prefers finite totalCost over cost (0 is a valid total)', () => {
|
||||
const a = computeBarAnalyticsFromDaily(
|
||||
[],
|
||||
[
|
||||
hourly({
|
||||
hour: utcHourKeyForLocal(localDayAt(11)),
|
||||
cost: 2.2,
|
||||
totalCost: 0,
|
||||
requestCount: 1,
|
||||
}),
|
||||
],
|
||||
NOW
|
||||
);
|
||||
// totalCost = 0 is finite so it wins — the bucket stays 0.
|
||||
expect(a.byHour[11].cost).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
@@ -987,6 +987,137 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// force flag threading: refresh=true passes {force:true} to getNativeAccountRows
|
||||
// ============================================================================
|
||||
|
||||
describe('/summary force flag passed to getNativeAccountRows', () => {
|
||||
async function buildForceFlagRouter(
|
||||
onNativeCall: (opts: { force?: boolean } | undefined) => void
|
||||
) {
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const router = createBarRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
|
||||
getCachedQuota: () => makeQuotaResult(),
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
getNativeAccountRows: async (opts) => {
|
||||
onNativeCall(opts);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
app.use('/api/bar', router);
|
||||
const srv = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('No server address');
|
||||
resetDebounce();
|
||||
return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` };
|
||||
}
|
||||
|
||||
it('refresh=true (after debounce reset) passes { force: true } to getNativeAccountRows', async () => {
|
||||
const calls: Array<{ force?: boolean } | undefined> = [];
|
||||
const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts));
|
||||
|
||||
await getJson<BarSummaryRow[]>(url, '/api/bar/summary?refresh=true');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
// The first (and only) call must carry force: true
|
||||
expect(calls[0]?.force).toBe(true);
|
||||
});
|
||||
|
||||
it('refresh=false (no query param) passes { force: false } to getNativeAccountRows', async () => {
|
||||
const calls: Array<{ force?: boolean } | undefined> = [];
|
||||
const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts));
|
||||
|
||||
await getJson<BarSummaryRow[]>(url, '/api/bar/summary');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
// No refresh param → doForceRefresh is false
|
||||
expect(calls[0]?.force).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to cached native rows when the live native side-load fails (cards do not vanish)', async () => {
|
||||
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
|
||||
'../../../src/web-server/routes/bar-routes'
|
||||
);
|
||||
const cachedCodex: BarSummaryRow = {
|
||||
account_id: 'codex',
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
tier: 'pro',
|
||||
paused: false,
|
||||
quota_percentage: 42,
|
||||
quotaStatus: 'ok',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: true,
|
||||
fetchedAt: '2026-06-09T14:00:00.000Z',
|
||||
needsReauth: false,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = createBarRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
|
||||
getCachedQuota: () => makeQuotaResult(),
|
||||
setCachedQuota: () => {},
|
||||
invalidateQuotaCache: () => {},
|
||||
fetchAccountQuota: async () => makeQuotaResult(),
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
runHealthChecks: async () => makeHealthReport(),
|
||||
// Live native fetch unavailable → withTimeout resolves null → fallback.
|
||||
getNativeAccountRows: async () => {
|
||||
throw new Error('native unavailable');
|
||||
},
|
||||
getCachedNativeRows: () => [cachedCodex],
|
||||
});
|
||||
app.use('/api/bar', router);
|
||||
const srv = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('No server address');
|
||||
resetDebounce();
|
||||
const url = `http://127.0.0.1:${(addr as { port: number }).port}`;
|
||||
|
||||
const { status, body } = await getJson<BarSummaryRow[]>(url, '/api/bar/summary?refresh=true');
|
||||
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||
|
||||
// Response is healthy and the cached Codex row is served, not dropped.
|
||||
expect(status).toBe(200);
|
||||
expect(body.some((r) => r.provider === 'codex')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Native subscription rows side-load into /summary
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* Tests for the native subscription quota collector.
|
||||
*
|
||||
* The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage
|
||||
* endpoint. A controllable clock drives TTL / backoff / breaker assertions.
|
||||
* Neither the Anthropic nor the ChatGPT endpoint is ever hit — all fetches are
|
||||
* injected via NativeQuotaDeps. A controllable clock drives TTL / backoff /
|
||||
* breaker assertions for both the Claude and Codex paths.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getNativeAccountRows,
|
||||
getCachedNativeAccountRows,
|
||||
resetNativeQuotaState,
|
||||
type NativeQuotaDeps,
|
||||
} from '../../../src/web-server/usage/native-quota-collector';
|
||||
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
||||
import type { ClaudeQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
||||
import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials';
|
||||
import type { CodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector';
|
||||
|
||||
// A jump comfortably past any single-call backoff cooldown (<= 60s), used so a
|
||||
// breaker-test fetch is not blocked by the prior 429's per-call cooldown.
|
||||
@@ -351,11 +354,331 @@ describe('stale-on-fail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Codex network helpers
|
||||
// ============================================================================
|
||||
|
||||
function codexSuccessQuota(): CodexQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
label: 'Primary',
|
||||
remainingPercent: 60,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: '2026-06-09T19:00:00.000Z',
|
||||
},
|
||||
weekly: {
|
||||
label: 'Secondary',
|
||||
remainingPercent: 80,
|
||||
resetAfterSeconds: 86400 * 4,
|
||||
resetAt: '2026-06-13T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
planType: 'pro',
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'codex-user@example.com',
|
||||
};
|
||||
}
|
||||
|
||||
function codexRateLimitedQuota(retryAfter?: string): CodexQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'codex-user@example.com',
|
||||
httpStatus: 429,
|
||||
retryable: true,
|
||||
...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}),
|
||||
error: 'rate limited',
|
||||
};
|
||||
}
|
||||
|
||||
function codexReatuhQuota(): CodexQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
accountId: 'codex-user@example.com',
|
||||
needsReauth: true,
|
||||
error: 'Token expired',
|
||||
};
|
||||
}
|
||||
|
||||
function codexLocalQuota(): CodexLocalQuota {
|
||||
return {
|
||||
quotaPercentage: 30,
|
||||
nextReset: '2026-06-09T19:00:00.000Z',
|
||||
tier: 'pro',
|
||||
stale: true,
|
||||
staleAsOf: '2026-06-09T13:30:00.000Z',
|
||||
windows: [
|
||||
{
|
||||
key: 'five_hour',
|
||||
label: '5h',
|
||||
usedPercent: 70,
|
||||
remainingPercent: 30,
|
||||
resetAt: '2026-06-09T19:00:00.000Z',
|
||||
windowMinutes: 300,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a NativeQuotaDeps that completely stubs the Codex path.
|
||||
* Claude path is disabled (no credentials) so only the Codex row is produced.
|
||||
*/
|
||||
function makeCodexDeps(
|
||||
overrides: Partial<NativeQuotaDeps> & { clock: { now: number } }
|
||||
): NativeQuotaDeps & { networkCount: () => number; localCount: () => number } {
|
||||
let networkCalls = 0;
|
||||
let localCalls = 0;
|
||||
const { clock, ...rest } = overrides;
|
||||
return {
|
||||
// Disable Claude path
|
||||
readCredentials: () => null,
|
||||
// Default network quota: success
|
||||
fetchCodexNetworkQuota: async (_id: string) => {
|
||||
networkCalls += 1;
|
||||
return codexSuccessQuota();
|
||||
},
|
||||
// Default local fallback: stale data
|
||||
getCodexQuota: async () => {
|
||||
localCalls += 1;
|
||||
return codexLocalQuota();
|
||||
},
|
||||
// Default account id resolved
|
||||
getDefaultCodexAccountId: () => 'codex-user@example.com',
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
// Allow overriding any dep
|
||||
...rest,
|
||||
networkCount: () => networkCalls,
|
||||
localCount: () => localCalls,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex network path tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Codex network path', () => {
|
||||
it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeCodexDeps({ clock });
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
expect(row?.health).toBe('ok');
|
||||
expect(row?.tier).toBe('pro');
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
// No staleAsOf on a live result
|
||||
expect(row?.staleAsOf).toBeUndefined();
|
||||
// quota_percentage = min(60, 80) = 60
|
||||
expect(row?.quota_percentage).toBe(60);
|
||||
// next_reset = soonest = fiveHour resetAt
|
||||
expect(row?.next_reset).toBe('2026-06-09T19:00:00.000Z');
|
||||
// quotaWindows: five_hour + seven_day
|
||||
expect(row?.quotaWindows).toHaveLength(2);
|
||||
const fiveHr = row?.quotaWindows?.find((w) => w.key === 'five_hour');
|
||||
expect(fiveHr?.label).toBe('5h');
|
||||
expect(fiveHr?.remainingPercent).toBe(60);
|
||||
expect(fiveHr?.usedPercent).toBe(40);
|
||||
expect(fiveHr?.windowMinutes).toBe(300);
|
||||
expect(fiveHr?.resetAt).toBe('2026-06-09T19:00:00.000Z');
|
||||
const week = row?.quotaWindows?.find((w) => w.key === 'seven_day');
|
||||
expect(week?.label).toBe('week');
|
||||
expect(week?.remainingPercent).toBe(80);
|
||||
expect(week?.usedPercent).toBe(20);
|
||||
expect(week?.windowMinutes).toBe(10080);
|
||||
// Network was called; local was NOT (network succeeded)
|
||||
expect(deps.networkCount()).toBe(1);
|
||||
expect(deps.localCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('network failure falls back to local stale row — staleAsOf set, health warning', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeCodexDeps({
|
||||
clock,
|
||||
fetchCodexNetworkQuota: async () => ({
|
||||
success: false,
|
||||
windows: [],
|
||||
planType: null,
|
||||
lastUpdated: Date.now(),
|
||||
error: 'network error',
|
||||
retryable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.health).toBe('warning');
|
||||
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
||||
expect(deps.localCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('force bypasses TTL and re-fetches from network', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeCodexDeps({ clock });
|
||||
|
||||
// Prime the cache
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.networkCount()).toBe(1);
|
||||
|
||||
// Normal call within TTL: served from cache
|
||||
clock.now += 5 * 60 * 1000;
|
||||
await getNativeAccountRows(deps);
|
||||
expect(deps.networkCount()).toBe(1);
|
||||
|
||||
// Force call: bypasses TTL, re-fetches
|
||||
await getNativeAccountRows(deps, { force: true });
|
||||
expect(deps.networkCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('token_expired (needsReauth) returns a reauth row — not cached as good', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeCodexDeps({
|
||||
clock,
|
||||
fetchCodexNetworkQuota: async () => codexReatuhQuota(),
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
|
||||
expect(row?.quotaStatus).toBe('error');
|
||||
expect(row?.health).toBe('error');
|
||||
expect(row?.needsReauth).toBe(true);
|
||||
// Local fallback was NOT called (reauth is a distinct path, not a transient error)
|
||||
expect(deps.localCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('breaker-open path: skips network and serves local fallback', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let networkCalls = 0;
|
||||
let localCalls = 0;
|
||||
|
||||
const deps: NativeQuotaDeps & { networkCount: () => number; localCount: () => number } = {
|
||||
readCredentials: () => null,
|
||||
getDefaultCodexAccountId: () => 'codex-user@example.com',
|
||||
fetchCodexNetworkQuota: async () => {
|
||||
networkCalls += 1;
|
||||
return codexRateLimitedQuota();
|
||||
},
|
||||
getCodexQuota: async () => {
|
||||
localCalls += 1;
|
||||
return codexLocalQuota();
|
||||
},
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
networkCount: () => networkCalls,
|
||||
localCount: () => localCalls,
|
||||
};
|
||||
|
||||
// Trip the breaker with 3 consecutive 429s
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await getNativeAccountRows(deps);
|
||||
// advance past per-call cooldown between each
|
||||
clock.now += 62_000;
|
||||
}
|
||||
const callsAfterTrip = networkCalls;
|
||||
|
||||
// Breaker is now open: next call must skip network
|
||||
await getNativeAccountRows(deps);
|
||||
expect(networkCalls).toBe(callsAfterTrip); // no new network call
|
||||
// Local fallback is called instead
|
||||
expect(localCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('no-account path: skips network and serves local fallback', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
let networkCalls = 0;
|
||||
let localCalls = 0;
|
||||
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null,
|
||||
getDefaultCodexAccountId: () => null, // no account configured
|
||||
fetchCodexNetworkQuota: async () => {
|
||||
networkCalls += 1;
|
||||
return codexSuccessQuota();
|
||||
},
|
||||
getCodexQuota: async () => {
|
||||
localCalls += 1;
|
||||
return codexLocalQuota();
|
||||
},
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
};
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
|
||||
// No network call since no account
|
||||
expect(networkCalls).toBe(0);
|
||||
// Local fallback was used
|
||||
expect(localCalls).toBe(1);
|
||||
// Row is present from local data
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.health).toBe('warning'); // local stale
|
||||
});
|
||||
|
||||
it('network success with EMPTY coreUsage falls back to local (no contentless ok row)', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
// Track our own network counter — an override passed to makeCodexDeps
|
||||
// replaces the helper's default counting fetcher, so deps.networkCount()
|
||||
// would not see this override's calls.
|
||||
let networkCalls = 0;
|
||||
const deps = makeCodexDeps({
|
||||
clock,
|
||||
// Healthy response but no resolved core windows (e.g. only code-review
|
||||
// windows or a changed payload) — carries no glanceable 5h/weekly signal.
|
||||
fetchCodexNetworkQuota: async () => {
|
||||
networkCalls += 1;
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: 'pro',
|
||||
lastUpdated: clock.now,
|
||||
accountId: 'codex-user@example.com',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const row = rows.find((r) => r.provider === 'codex');
|
||||
|
||||
// Network was attempted, but the empty result must NOT be cached as an "ok"
|
||||
// row — the local fallback supplies real (stale) data instead.
|
||||
expect(networkCalls).toBe(1);
|
||||
expect(deps.localCount()).toBe(1);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.health).toBe('warning'); // came from local stale path
|
||||
expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z');
|
||||
expect(row?.quotaWindows).toHaveLength(1); // local five_hour window
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Codex path (original local-only tests, preserved)
|
||||
// These tests explicitly set getDefaultCodexAccountId: () => null so the
|
||||
// network path is skipped and only the local fallback is exercised.
|
||||
// ============================================================================
|
||||
|
||||
describe('Codex path', () => {
|
||||
it('maps a local Codex quota into a codex ok row', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null, // no claude row
|
||||
getDefaultCodexAccountId: () => null, // disable network path
|
||||
getCodexQuota: async () => ({
|
||||
quotaPercentage: 52,
|
||||
nextReset: '2026-06-09T19:00:00.000Z',
|
||||
@@ -398,6 +721,7 @@ describe('Codex path', () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null,
|
||||
getDefaultCodexAccountId: () => null, // disable network path
|
||||
getCodexQuota: async () => ({
|
||||
quotaPercentage: 10,
|
||||
nextReset: null,
|
||||
@@ -419,6 +743,7 @@ describe('Codex path', () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps: NativeQuotaDeps = {
|
||||
readCredentials: () => null,
|
||||
getDefaultCodexAccountId: () => null, // disable network path
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
};
|
||||
@@ -426,3 +751,27 @@ describe('Codex path', () => {
|
||||
expect(rows.find((r) => r.provider === 'codex')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
|
||||
it('returns [] before any successful collect', () => {
|
||||
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the last cached codex row (cached=true) after a successful collect, [] after reset', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeCodexDeps({ clock });
|
||||
|
||||
// Prime the cache via a successful network collect.
|
||||
await getNativeAccountRows(deps);
|
||||
|
||||
const cached = getCachedNativeAccountRows();
|
||||
const codex = cached.find((r) => r.provider === 'codex');
|
||||
expect(codex).toBeDefined();
|
||||
expect(codex?.cached).toBe(true);
|
||||
// No additional network call — the accessor reads cache only.
|
||||
expect(deps.networkCount()).toBe(1);
|
||||
|
||||
resetNativeQuotaState();
|
||||
expect(getCachedNativeAccountRows()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user