Skip to content

Commit eb92455

Browse files
committed
test: add unit suites for the extracted credential and quota-cache helpers
Addresses the review note that account-credentials.ts and quota-cache-helpers.ts became public API in phase 3 without direct coverage. 25 deterministic tests pin the 5-minute access-token freshness boundary (strictly greater-than), the short/"token-" refresh-token heuristics, org/manual identity stability vs token-sourced auto-follow, the persisted-429 quota view synthesis and reset-merging, the unique-id/email-fallback write rules with stale-entry pruning, cache cloning isolation, and unsafe-email pruning. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
1 parent 477f9ba commit eb92455

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
applyTokenAccountIdentity,
4+
hasLikelyInvalidRefreshToken,
5+
hasUsableAccessToken,
6+
resolveStoredAccountIdentity,
7+
} from "../lib/codex-manager/account-credentials.js";
8+
9+
const FRESH_WINDOW_MS = 5 * 60 * 1000;
10+
11+
describe("hasUsableAccessToken", () => {
12+
const now = 1_700_000_000_000;
13+
14+
it("requires an access token and a finite expiry", () => {
15+
expect(hasUsableAccessToken({ accessToken: undefined, expiresAt: now + 10 * 60 * 1000 }, now)).toBe(false);
16+
expect(hasUsableAccessToken({ accessToken: "", expiresAt: now + 10 * 60 * 1000 }, now)).toBe(false);
17+
expect(hasUsableAccessToken({ accessToken: "access", expiresAt: undefined }, now)).toBe(false);
18+
expect(
19+
hasUsableAccessToken({ accessToken: "access", expiresAt: Number.POSITIVE_INFINITY }, now),
20+
).toBe(false);
21+
expect(hasUsableAccessToken({ accessToken: "access", expiresAt: Number.NaN }, now)).toBe(false);
22+
});
23+
24+
it("pins the 5-minute freshness boundary as strictly greater-than", () => {
25+
expect(
26+
hasUsableAccessToken({ accessToken: "access", expiresAt: now + FRESH_WINDOW_MS }, now),
27+
).toBe(false);
28+
expect(
29+
hasUsableAccessToken({ accessToken: "access", expiresAt: now + FRESH_WINDOW_MS + 1 }, now),
30+
).toBe(true);
31+
expect(hasUsableAccessToken({ accessToken: "access", expiresAt: now - 1 }, now)).toBe(false);
32+
});
33+
});
34+
35+
describe("hasLikelyInvalidRefreshToken", () => {
36+
it("treats missing, short, and placeholder tokens as invalid", () => {
37+
expect(hasLikelyInvalidRefreshToken(undefined)).toBe(true);
38+
expect(hasLikelyInvalidRefreshToken("")).toBe(true);
39+
expect(hasLikelyInvalidRefreshToken("short-token")).toBe(true);
40+
expect(hasLikelyInvalidRefreshToken(` ${"x".repeat(10)} `)).toBe(true);
41+
// The "token-" prefix heuristic catches test fixtures regardless of length.
42+
expect(hasLikelyInvalidRefreshToken(`token-${"x".repeat(40)}`)).toBe(true);
43+
});
44+
45+
it("accepts realistic long refresh tokens", () => {
46+
expect(hasLikelyInvalidRefreshToken("rf_".padEnd(40, "a"))).toBe(false);
47+
expect(hasLikelyInvalidRefreshToken(` ${"a".repeat(20)} `)).toBe(false);
48+
});
49+
});
50+
51+
describe("resolveStoredAccountIdentity", () => {
52+
it("returns empty when nothing resolves", () => {
53+
expect(resolveStoredAccountIdentity(undefined, undefined, undefined)).toEqual({});
54+
});
55+
56+
it("adopts the token identity when nothing is stored", () => {
57+
expect(resolveStoredAccountIdentity(undefined, undefined, "acc_token")).toEqual({
58+
accountId: "acc_token",
59+
accountIdSource: "token",
60+
});
61+
});
62+
63+
it("keeps org and manual selections stable across token changes", () => {
64+
expect(resolveStoredAccountIdentity("acc_org", "org", "acc_token")).toEqual({
65+
accountId: "acc_org",
66+
accountIdSource: "org",
67+
});
68+
expect(resolveStoredAccountIdentity("acc_manual", "manual", "acc_token")).toEqual({
69+
accountId: "acc_manual",
70+
accountIdSource: "manual",
71+
});
72+
});
73+
74+
it("follows token changes for token-sourced identities", () => {
75+
expect(resolveStoredAccountIdentity("acc_old", "token", "acc_new")).toEqual({
76+
accountId: "acc_new",
77+
accountIdSource: "token",
78+
});
79+
});
80+
81+
it("keeps the stored id and source when the token offers nothing", () => {
82+
expect(resolveStoredAccountIdentity("acc_old", "token", undefined)).toEqual({
83+
accountId: "acc_old",
84+
accountIdSource: "token",
85+
});
86+
});
87+
});
88+
89+
describe("applyTokenAccountIdentity", () => {
90+
it("mutates the account and reports a change when the identity moves", () => {
91+
const account: { accountId?: string; accountIdSource?: "token" | "id_token" | "org" | "manual" } = {};
92+
expect(applyTokenAccountIdentity(account, "acc_token")).toBe(true);
93+
expect(account).toEqual({ accountId: "acc_token", accountIdSource: "token" });
94+
});
95+
96+
it("reports no change when nothing resolves or the identity is identical", () => {
97+
const empty: { accountId?: string } = {};
98+
expect(applyTokenAccountIdentity(empty, undefined)).toBe(false);
99+
expect(empty).toEqual({});
100+
101+
const settled = { accountId: "acc_token", accountIdSource: "token" as const };
102+
expect(applyTokenAccountIdentity(settled, "acc_token")).toBe(false);
103+
expect(settled).toEqual({ accountId: "acc_token", accountIdSource: "token" });
104+
});
105+
106+
it("does not overwrite a manual selection", () => {
107+
const manual = { accountId: "acc_manual", accountIdSource: "manual" as const };
108+
expect(applyTokenAccountIdentity(manual, "acc_token")).toBe(false);
109+
expect(manual).toEqual({ accountId: "acc_manual", accountIdSource: "manual" });
110+
});
111+
});
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { QuotaCacheData, QuotaCacheEntry } from "../lib/quota-cache.js";
3+
import type { CodexQuotaSnapshot } from "../lib/quota-probe.js";
4+
import {
5+
cloneQuotaCacheData,
6+
getPersistedQuotaViewForAccount,
7+
pruneUnsafeQuotaEmailCacheEntry,
8+
updateQuotaCacheForAccount,
9+
} from "../lib/codex-manager/quota-cache-helpers.js";
10+
import { DEFAULT_MODEL } from "../lib/request/helpers/model-map.js";
11+
12+
const NOW = 1_700_000_000_000;
13+
14+
function makeEntry(overrides: Partial<QuotaCacheEntry> = {}): QuotaCacheEntry {
15+
return {
16+
updatedAt: NOW - 1_000,
17+
status: 200,
18+
model: "gpt-5-codex",
19+
primary: { usedPercent: 50, windowMinutes: 300, resetAtMs: NOW + 10_000 },
20+
secondary: { usedPercent: 10, windowMinutes: 10080, resetAtMs: NOW + 20_000 },
21+
...overrides,
22+
};
23+
}
24+
25+
function makeSnapshot(overrides: Partial<CodexQuotaSnapshot> = {}): CodexQuotaSnapshot {
26+
return {
27+
status: 200,
28+
model: "gpt-5-codex",
29+
primary: { usedPercent: 75, windowMinutes: 300, resetAtMs: NOW + 30_000 },
30+
secondary: { usedPercent: 20, windowMinutes: 10080, resetAtMs: NOW + 40_000 },
31+
...overrides,
32+
};
33+
}
34+
35+
function makeCache(overrides: Partial<QuotaCacheData> = {}): QuotaCacheData {
36+
return { byAccountId: {}, byEmail: {}, ...overrides };
37+
}
38+
39+
const uniqueAccount = { accountId: "acc_a", email: "a@example.com" };
40+
const accounts = [uniqueAccount, { accountId: "acc_b", email: "b@example.com" }];
41+
42+
describe("getPersistedQuotaViewForAccount", () => {
43+
it("returns the cached entry for a unique account id when no persisted reset exists", () => {
44+
const entry = makeEntry();
45+
const cache = makeCache({ byAccountId: { acc_a: entry } });
46+
expect(
47+
getPersistedQuotaViewForAccount(cache, uniqueAccount, accounts, NOW),
48+
).toBe(entry);
49+
});
50+
51+
it("returns null with no cache and no persisted rate-limit reset", () => {
52+
expect(getPersistedQuotaViewForAccount(null, uniqueAccount, accounts, NOW)).toBeNull();
53+
});
54+
55+
it("synthesizes a 429 view from a persisted reset when the cache is empty", () => {
56+
const account = {
57+
...uniqueAccount,
58+
rateLimitResetTimes: { codex: NOW + 60_000 },
59+
};
60+
expect(
61+
getPersistedQuotaViewForAccount(null, account, accounts, NOW),
62+
).toEqual({
63+
updatedAt: NOW,
64+
status: 429,
65+
model: DEFAULT_MODEL,
66+
planType: undefined,
67+
primary: { resetAtMs: NOW + 60_000 },
68+
secondary: {},
69+
});
70+
});
71+
72+
it("keeps a cached 429 that already covers the persisted reset", () => {
73+
const entry = makeEntry({
74+
status: 429,
75+
primary: { usedPercent: 100, windowMinutes: 300, resetAtMs: NOW + 90_000 },
76+
});
77+
const cache = makeCache({ byAccountId: { acc_a: entry } });
78+
const account = {
79+
...uniqueAccount,
80+
rateLimitResetTimes: { codex: NOW + 60_000 },
81+
};
82+
expect(getPersistedQuotaViewForAccount(cache, account, accounts, NOW)).toBe(entry);
83+
});
84+
85+
it("upgrades a cached 200 to a 429 view with the max of cached and persisted resets", () => {
86+
const entry = makeEntry();
87+
const cache = makeCache({ byAccountId: { acc_a: entry } });
88+
const account = {
89+
...uniqueAccount,
90+
rateLimitResetTimes: { codex: NOW + 60_000 },
91+
};
92+
const view = getPersistedQuotaViewForAccount(cache, account, accounts, NOW);
93+
expect(view).toMatchObject({
94+
status: 429,
95+
model: "gpt-5-codex",
96+
updatedAt: entry.updatedAt,
97+
});
98+
expect(view?.primary.resetAtMs).toBe(NOW + 60_000);
99+
expect(view?.primary.usedPercent).toBe(50);
100+
expect(view?.secondary).toBe(entry.secondary);
101+
});
102+
});
103+
104+
describe("updateQuotaCacheForAccount", () => {
105+
beforeEach(() => {
106+
vi.useFakeTimers();
107+
vi.setSystemTime(NOW);
108+
});
109+
110+
afterEach(() => {
111+
vi.useRealTimers();
112+
});
113+
114+
it("writes by unique account id and drops the now-redundant email entry", () => {
115+
const cache = makeCache({
116+
byEmail: { "a@example.com": makeEntry() },
117+
});
118+
expect(updateQuotaCacheForAccount(cache, uniqueAccount, makeSnapshot(), accounts)).toBe(true);
119+
expect(cache.byAccountId.acc_a).toMatchObject({
120+
updatedAt: NOW,
121+
status: 200,
122+
primary: { usedPercent: 75, windowMinutes: 300, resetAtMs: NOW + 30_000 },
123+
});
124+
expect(cache.byEmail).toEqual({});
125+
});
126+
127+
it("falls back to the email key when the account id is not unique", () => {
128+
const duplicated = [
129+
{ accountId: "acc_dup", email: "a@example.com" },
130+
{ accountId: "acc_dup", email: "b@example.com" },
131+
];
132+
const cache = makeCache();
133+
expect(
134+
updateQuotaCacheForAccount(cache, duplicated[0], makeSnapshot(), duplicated),
135+
).toBe(true);
136+
expect(cache.byAccountId).toEqual({});
137+
expect(cache.byEmail["a@example.com"]).toMatchObject({ updatedAt: NOW, status: 200 });
138+
});
139+
140+
it("reports no change when neither key is safe to write", () => {
141+
// Same id AND same email across two accounts: no unique id, no safe
142+
// email fallback, and nothing cached to prune.
143+
const clones = [
144+
{ accountId: "acc_dup", email: "shared@example.com" },
145+
{ accountId: "acc_dup", email: "shared@example.com" },
146+
];
147+
const cache = makeCache();
148+
expect(updateQuotaCacheForAccount(cache, clones[0], makeSnapshot(), clones)).toBe(false);
149+
expect(cache).toEqual(makeCache());
150+
});
151+
152+
it("prunes a stale email entry when the fallback became unsafe", () => {
153+
const clones = [
154+
{ accountId: "acc_dup", email: "shared@example.com" },
155+
{ accountId: "acc_dup", email: "shared@example.com" },
156+
];
157+
const cache = makeCache({
158+
byEmail: { "shared@example.com": makeEntry() },
159+
});
160+
expect(updateQuotaCacheForAccount(cache, clones[0], makeSnapshot(), clones)).toBe(true);
161+
expect(cache.byEmail).toEqual({});
162+
});
163+
});
164+
165+
describe("cloneQuotaCacheData", () => {
166+
it("clones the maps so mutations do not leak back", () => {
167+
const original = makeCache({
168+
byAccountId: { acc_a: makeEntry() },
169+
byEmail: { "a@example.com": makeEntry() },
170+
});
171+
const clone = cloneQuotaCacheData(original);
172+
expect(clone).toEqual(original);
173+
expect(clone).not.toBe(original);
174+
delete clone.byAccountId.acc_a;
175+
clone.byEmail["c@example.com"] = makeEntry();
176+
expect(original.byAccountId.acc_a).toBeDefined();
177+
expect(original.byEmail["c@example.com"]).toBeUndefined();
178+
});
179+
});
180+
181+
describe("pruneUnsafeQuotaEmailCacheEntry", () => {
182+
it("returns false when the email has no cache entry", () => {
183+
expect(pruneUnsafeQuotaEmailCacheEntry(makeCache(), "a@example.com", accounts)).toBe(false);
184+
expect(pruneUnsafeQuotaEmailCacheEntry(makeCache(), undefined, accounts)).toBe(false);
185+
});
186+
187+
it("keeps the entry while exactly one account still owns the email", () => {
188+
const cache = makeCache({ byEmail: { "a@example.com": makeEntry() } });
189+
expect(pruneUnsafeQuotaEmailCacheEntry(cache, "A@Example.com ", accounts)).toBe(false);
190+
expect(cache.byEmail["a@example.com"]).toBeDefined();
191+
});
192+
193+
it("prunes the entry once the email is shared by multiple accounts", () => {
194+
const shared = [
195+
{ accountId: "acc_a", email: "a@example.com" },
196+
{ accountId: "acc_c", email: "a@example.com" },
197+
];
198+
const cache = makeCache({ byEmail: { "a@example.com": makeEntry() } });
199+
expect(pruneUnsafeQuotaEmailCacheEntry(cache, "a@example.com", shared)).toBe(true);
200+
expect(cache.byEmail).toEqual({});
201+
});
202+
});

0 commit comments

Comments
 (0)