178 lines
5.8 KiB
TypeScript
178 lines
5.8 KiB
TypeScript
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js";
|
||
|
|
import { fetchClaudeUsage } from "./provider-usage.fetch.claude.js";
|
||
|
|
|
||
|
|
const MISSING_SCOPE_MESSAGE = "missing scope requirement user:profile";
|
||
|
|
|
||
|
|
function makeMissingScopeResponse() {
|
||
|
|
return makeResponse(403, {
|
||
|
|
error: { message: MISSING_SCOPE_MESSAGE },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function expectMissingScopeError(result: Awaited<ReturnType<typeof fetchClaudeUsage>>) {
|
||
|
|
expect(result.error).toBe(`HTTP 403: ${MISSING_SCOPE_MESSAGE}`);
|
||
|
|
expect(result.windows).toHaveLength(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function createScopeFallbackFetch(handler: (url: string) => Promise<Response> | Response) {
|
||
|
|
return createProviderUsageFetch(async (url) => {
|
||
|
|
if (url.includes("/api/oauth/usage")) {
|
||
|
|
return makeMissingScopeResponse();
|
||
|
|
}
|
||
|
|
return handler(url);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
type ScopeFallbackFetch = ReturnType<typeof createScopeFallbackFetch>;
|
||
|
|
|
||
|
|
async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) {
|
||
|
|
const result = await fetchClaudeUsage("token", 5000, mockFetch);
|
||
|
|
expectMissingScopeError(result);
|
||
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeOrgAResponse() {
|
||
|
|
return makeResponse(200, [{ uuid: "org-a" }]);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("fetchClaudeUsage", () => {
|
||
|
|
afterEach(() => {
|
||
|
|
vi.unstubAllEnvs();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("parses oauth usage windows", async () => {
|
||
|
|
const fiveHourReset = "2026-01-08T00:00:00Z";
|
||
|
|
const weekReset = "2026-01-12T00:00:00Z";
|
||
|
|
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
||
|
|
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||
|
|
expect(headers.Authorization).toBe("Bearer token");
|
||
|
|
expect(headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
||
|
|
|
||
|
|
return makeResponse(200, {
|
||
|
|
five_hour: { utilization: 18, resets_at: fiveHourReset },
|
||
|
|
seven_day: { utilization: 54, resets_at: weekReset },
|
||
|
|
seven_day_sonnet: { utilization: 67 },
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await fetchClaudeUsage("token", 5000, mockFetch);
|
||
|
|
|
||
|
|
expect(result.windows).toEqual([
|
||
|
|
{ label: "5h", usedPercent: 18, resetAt: new Date(fiveHourReset).getTime() },
|
||
|
|
{ label: "Week", usedPercent: 54, resetAt: new Date(weekReset).getTime() },
|
||
|
|
{ label: "Sonnet", usedPercent: 67 },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns HTTP errors with provider message suffix", async () => {
|
||
|
|
const mockFetch = createProviderUsageFetch(async () =>
|
||
|
|
makeResponse(403, {
|
||
|
|
error: { message: "scope not granted" },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const result = await fetchClaudeUsage("token", 5000, mockFetch);
|
||
|
|
expect(result.error).toBe("HTTP 403: scope not granted");
|
||
|
|
expect(result.windows).toHaveLength(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("falls back to claude web usage when oauth scope is missing", async () => {
|
||
|
|
vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-session-key");
|
||
|
|
|
||
|
|
const mockFetch = createProviderUsageFetch(async (url, init) => {
|
||
|
|
if (url.includes("/api/oauth/usage")) {
|
||
|
|
return makeMissingScopeResponse();
|
||
|
|
}
|
||
|
|
|
||
|
|
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||
|
|
expect(headers.Cookie).toBe("sessionKey=sk-ant-session-key");
|
||
|
|
|
||
|
|
if (url.endsWith("/api/organizations")) {
|
||
|
|
return makeResponse(200, [{ uuid: "org-123" }]);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (url.endsWith("/api/organizations/org-123/usage")) {
|
||
|
|
return makeResponse(200, {
|
||
|
|
five_hour: { utilization: 12 },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return makeResponse(404, "not found");
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await fetchClaudeUsage("token", 5000, mockFetch);
|
||
|
|
|
||
|
|
expect(result.error).toBeUndefined();
|
||
|
|
expect(result.windows).toEqual([{ label: "5h", usedPercent: 12, resetAt: undefined }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("keeps oauth error when cookie header cannot be parsed into a session key", async () => {
|
||
|
|
vi.stubEnv("CLAUDE_WEB_COOKIE", "sessionKey=sk-ant-cookie-session");
|
||
|
|
|
||
|
|
const mockFetch = createScopeFallbackFetch(async (url) => {
|
||
|
|
if (url.endsWith("/api/organizations")) {
|
||
|
|
return makeResponse(200, [{ uuid: "org-cookie" }]);
|
||
|
|
}
|
||
|
|
if (url.endsWith("/api/organizations/org-cookie/usage")) {
|
||
|
|
return makeResponse(200, { seven_day_opus: { utilization: 44 } });
|
||
|
|
}
|
||
|
|
return makeResponse(404, "not found");
|
||
|
|
});
|
||
|
|
|
||
|
|
await expectMissingScopeWithoutFallback(mockFetch);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("keeps oauth error when fallback session key is unavailable", async () => {
|
||
|
|
const mockFetch = createScopeFallbackFetch(async (url) => {
|
||
|
|
if (url.endsWith("/api/organizations")) {
|
||
|
|
return makeResponse(200, [{ uuid: "org-missing-session" }]);
|
||
|
|
}
|
||
|
|
return makeResponse(404, "not found");
|
||
|
|
});
|
||
|
|
|
||
|
|
await expectMissingScopeWithoutFallback(mockFetch);
|
||
|
|
});
|
||
|
|
|
||
|
|
it.each([
|
||
|
|
{
|
||
|
|
name: "org list request fails",
|
||
|
|
orgResponse: () => makeResponse(500, "boom"),
|
||
|
|
usageResponse: () => makeResponse(200, {}),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "org list has no id",
|
||
|
|
orgResponse: () => makeResponse(200, [{}]),
|
||
|
|
usageResponse: () => makeResponse(200, {}),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "usage request fails",
|
||
|
|
orgResponse: makeOrgAResponse,
|
||
|
|
usageResponse: () => makeResponse(503, "down"),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "usage request has no windows",
|
||
|
|
orgResponse: makeOrgAResponse,
|
||
|
|
usageResponse: () => makeResponse(200, {}),
|
||
|
|
},
|
||
|
|
])(
|
||
|
|
"returns oauth error when web fallback is unavailable: $name",
|
||
|
|
async ({ orgResponse, usageResponse }) => {
|
||
|
|
vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-fallback");
|
||
|
|
|
||
|
|
const mockFetch = createScopeFallbackFetch(async (url) => {
|
||
|
|
if (url.endsWith("/api/organizations")) {
|
||
|
|
return orgResponse();
|
||
|
|
}
|
||
|
|
if (url.endsWith("/api/organizations/org-a/usage")) {
|
||
|
|
return usageResponse();
|
||
|
|
}
|
||
|
|
return makeResponse(404, "not found");
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await fetchClaudeUsage("token", 5000, mockFetch);
|
||
|
|
expectMissingScopeError(result);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
});
|