@@ -0,0 +1,158 @@
|
||||
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/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: String(input), headers: new Headers(init?.headers) });
|
||||
return Response.json(payload);
|
||||
},
|
||||
60_000,
|
||||
path,
|
||||
);
|
||||
await tracker.init();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.url).toBe("https://api.anthropic.com/api/oauth/usage");
|
||||
expect(calls[0]!.headers.get("authorization")).toBe("Bearer token-123");
|
||||
expect(calls[0]!.headers.get("anthropic-beta")).toBe("oauth-2025-04-20");
|
||||
expect(calls[0]!.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("missing credentials file yields no status", async () => {
|
||||
let calls = 0;
|
||||
const tracker = new ClaudeQuotaTracker(
|
||||
async () => {},
|
||||
async () => {
|
||||
calls++;
|
||||
return Response.json(payload);
|
||||
},
|
||||
60_000,
|
||||
path,
|
||||
);
|
||||
await tracker.init();
|
||||
expect(calls).toBe(0);
|
||||
expect(tracker.status()).toBeUndefined();
|
||||
await tracker.dispose();
|
||||
});
|
||||
|
||||
test("expired token skips fetch and keeps last snapshot", async () => {
|
||||
await writeFile(path, credentials(Date.now() + 3_600_000));
|
||||
let calls = 0;
|
||||
const tracker = new ClaudeQuotaTracker(
|
||||
async () => {},
|
||||
async () => {
|
||||
calls++;
|
||||
return Response.json(payload);
|
||||
},
|
||||
60_000,
|
||||
path,
|
||||
);
|
||||
await tracker.init();
|
||||
await writeFile(path, credentials(Date.now() - 1));
|
||||
await tracker.refresh();
|
||||
expect(calls).toBe(1);
|
||||
expect(tracker.status()?.windows).toHaveLength(4);
|
||||
await tracker.dispose();
|
||||
});
|
||||
Reference in New Issue
Block a user