Files
AgentHub/server/__tests__/cc-usage.test.js
T
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00

206 lines
6.3 KiB
JavaScript

/**
* @file cc-usage.test.js
* @description Tests for per-profile quota lookup.
*
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
* must be written back — the exchange retires the refresh token it was given,
* so dropping the result leaves an account the user has to log into again. And
* a still-valid token must NOT be refreshed, because every refresh rewrites
* credentials on disk and the point of a read is to read.
*
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
process.env.CLAUDE_HOME = TMP;
const profiles = require("../lib/cc-profiles");
const usage = require("../lib/cc-usage");
const realFetch = global.fetch;
let calls = [];
after(() => {
global.fetch = realFetch;
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
} catch {
/* best-effort temp cleanup */
}
});
function writeProfileFile(name, credentials) {
const dir = profiles.profilesDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
name,
label: "Label kept",
savedAt: 1,
account: { emailAddress: "a@b.c" },
credentials,
})
);
}
/** Answer the token endpoint and the usage endpoint, recording both. */
function stubFetch({ usageStatus = 200, usageBody } = {}) {
global.fetch = async (url, init) => {
calls.push({ url: String(url), init });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
access_token: "fresh-access",
refresh_token: "rotated-refresh",
expires_in: 28800,
}),
};
}
return {
ok: usageStatus === 200,
status: usageStatus,
json: async () =>
usageBody || {
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
seven_day_opus: null,
},
};
};
}
beforeEach(() => {
calls = [];
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
});
afterEach(() => {
global.fetch = realFetch;
});
describe("profileUsage", () => {
it("reports the two windows, and only those", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
// Nothing token-shaped may be in what a route is about to serialise.
assert.equal(JSON.stringify(result).includes("still-good"), false);
});
it("does not refresh a token that is still valid", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.equal(result.refreshed, false);
assert.equal(
calls.some((c) => c.url.includes("/v1/oauth/token")),
false,
"a read must not rewrite credentials on disk"
);
assert.match(calls[0].init.headers.Authorization, /still-good/);
});
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
// The exchange retires the refresh token it was handed. A refresh whose
// result is not persisted leaves the profile holding a dead credential —
// the account could never be activated again without a fresh login.
writeProfileFile("stale", {
accessToken: "expired",
refreshToken: "original-refresh",
expiresAt: Date.now() - 1000,
subscriptionType: "team",
});
stubFetch();
const result = await usage.profileUsage("stale");
assert.equal(result.refreshed, true);
const stored = profiles.readProfile("stale");
assert.equal(stored.credentials.accessToken, "fresh-access");
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
assert.ok(stored.credentials.expiresAt > Date.now());
// Everything else about the profile survives the rewrite.
assert.equal(stored.label, "Label kept");
assert.equal(stored.credentials.subscriptionType, "team");
assert.equal(stored.account.emailAddress, "a@b.c");
});
it("retries once through a refresh when a token that looked valid is refused", async () => {
writeProfileFile("revoked", {
accessToken: "revoked-but-unexpired",
refreshToken: "original-refresh",
expiresAt: Date.now() + 3_600_000,
});
let first = true;
global.fetch = async (url) => {
calls.push({ url: String(url) });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
};
}
if (first) {
first = false;
return { ok: false, status: 401, json: async () => ({}) };
}
return {
ok: true,
status: 200,
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
};
};
const result = await usage.profileUsage("revoked");
assert.equal(result.refreshed, true);
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
});
it("refuses a profile with no refresh token instead of half-answering", async () => {
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
stubFetch();
await assert.rejects(
() => usage.profileUsage("tokenless"),
(e) => e.code === "ENOREFRESH"
);
});
it("reports an unknown profile as ENOTFOUND", async () => {
stubFetch();
await assert.rejects(
() => usage.profileUsage("nope"),
(e) => e.code === "ENOTFOUND"
);
});
});