import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ClaudeQuotaTracker, normalizeClaudeQuota } from "../src/quota/claude-quota.js"; const payload = { five_hour: { utilization: 12.5, resets_at: "2026-10-07T12:00:00+00:00" }, seven_day: { utilization: 140, resets_at: null }, seven_day_opus: null, seven_day_sonnet: { utilization: 3, resets_at: "2026-10-10T00:00:00+00:00" }, limits: [ { kind: "session", percent: 12.5, resets_at: "2026-10-07T12:00:00+00:00", scope: null }, { kind: "weekly_scoped", percent: 40, resets_at: "2026-10-10T00:00:00+00:00", scope: { model: { id: "claude-fable-5-1", display_name: "Fable" } }, }, { kind: "weekly_scoped", percent: 3, resets_at: "2026-10-10T00:00:00+00:00", scope: { model: { id: "claude-sonnet-5-5", display_name: "Sonnet" } }, }, ], extra_usage: { is_enabled: false }, }; let dir: string; let path: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "claude-quota-")); path = join(dir, ".credentials.json"); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); function credentials(expiresAt: number) { return JSON.stringify({ claudeAiOauth: { accessToken: "token-123", refreshToken: "refresh", expiresAt, scopes: ["user:inference"], subscriptionType: "Max", rateLimitTier: "default_claude_max_20x", }, }); } test("normalizes Claude usage windows", () => { expect(normalizeClaudeQuota(payload, "Max", 1)).toEqual({ plan: "max", fetchedAt: 1, windows: [ { id: "claude:five_hour", label: null, seconds: 18_000, used: 12.5, resetsAt: Date.parse("2026-10-07T12:00:00Z"), }, { id: "claude:seven_day", label: null, seconds: 604_800, used: 100, resetsAt: Number.NaN, }, { id: "claude:seven_day_sonnet", label: "Sonnet", seconds: 604_800, used: 3, resetsAt: Date.parse("2026-10-10T00:00:00Z"), }, { id: "claude:weekly_scoped:claude-fable-5-1", label: "Fable", seconds: 604_800, used: 40, resetsAt: Date.parse("2026-10-10T00:00:00Z"), }, ], }); expect(normalizeClaudeQuota({ five_hour: null, seven_day: null }, "pro")).toBeUndefined(); }); test("reads credentials file and sends OAuth usage request", async () => { await writeFile(path, credentials(Date.now() + 3_600_000)); const calls: Array<{ url: string; headers: Headers }> = []; let changed = 0; const tracker = new ClaudeQuotaTracker( async () => { changed++; }, async (input, init) => { calls.push({ url: input instanceof Request ? input.url : String(input), headers: new Headers(init?.headers), }); return Response.json(payload); }, 60_000, path, ); await tracker.init(); expect(calls).toHaveLength(1); const [call] = calls; if (!call) throw new Error("Missing usage request"); expect(call.url).toBe("https://api.anthropic.com/api/oauth/usage"); expect(call.headers.get("authorization")).toBe("Bearer token-123"); expect(call.headers.get("anthropic-beta")).toBe("oauth-2025-04-20"); expect(call.headers.get("accept")).toBe("application/json"); expect(tracker.status()?.plan).toBe("max"); expect(tracker.status()?.windows[0]?.remaining).toBe(87.5); expect(changed).toBe(1); await rm(path); await tracker.refresh(); expect(tracker.status()).toBeUndefined(); expect(changed).toBe(2); await tracker.dispose(); }); test("failed fetches retry with re-read credentials and give up after five attempts", async () => { { await writeFile(path, credentials(Date.now() + 3_600_000)); const tokens: Array = []; let changed = 0; const tracker = new ClaudeQuotaTracker( async () => { changed++; }, async (_input, init) => { tokens.push(new Headers(init?.headers).get("authorization")); if (tokens.length > 1) return Response.json(payload); await writeFile( path, credentials(Date.now() + 3_600_000).replace("token-123", "token-456"), ); throw new Error("network down"); }, 60_000, path, 1, ); await tracker.init(); expect(tokens).toEqual(["Bearer token-123", "Bearer token-456"]); expect(tracker.status()?.plan).toBe("max"); expect(changed).toBe(1); await tracker.dispose(); } { await writeFile(path, credentials(Date.now() + 3_600_000)); let calls = 0; const tracker = new ClaudeQuotaTracker( async () => {}, async () => { calls++; return new Response("nope", { status: 500 }); }, 60_000, path, 1, ); await tracker.init(); expect(calls).toBe(5); expect(tracker.status()).toBeUndefined(); await tracker.refresh(); expect(calls).toBe(10); await tracker.dispose(); } });