Skip to content

Commit 10e978c

Browse files
committed
fix(api-usage): addresses PR review findings
- Derives ApiCallSource/ApiPool types from const arrays (single source of truth) - Uses z.enum() in Zod schemas, removing unsafe type cast - Validates apiSource in deriveSource to prevent invalid strings reaching storage - Moves SOURCE_LABELS to api-usage.ts with Record<ApiCallSource, string> exhaustiveness - Inlines checkAndResetIfExpired to single localStorage write - Removes _requestCallbacks.length guard (always populated by api-usage.ts) - Includes REST reset times in updateResetAt via Math.max - Replaces positional callback capture with invoke-all-callbacks pattern - Adds tests for guard conditions, multi-client callbacks, read-only guard, quota errors, null client path, and hermetic staleness cache
1 parent 6c8f06e commit 10e978c

File tree

9 files changed

+193
-97
lines changed

9 files changed

+193
-97
lines changed

src/app/components/settings/SettingsPage.tsx

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isSafeGitHubUrl, openGitHubUrl } from "../../lib/url";
99
import { relativeTime } from "../../lib/format";
1010
import { fetchOrgs } from "../../services/api";
1111
import { getClient } from "../../services/github";
12-
import { getUsageSnapshot, getUsageResetAt, resetUsageData, checkAndResetIfExpired } from "../../services/api-usage";
12+
import { getUsageSnapshot, getUsageResetAt, resetUsageData, checkAndResetIfExpired, SOURCE_LABELS } from "../../services/api-usage";
1313
import OrgSelector from "../onboarding/OrgSelector";
1414
import RepoSelector from "../onboarding/RepoSelector";
1515
import Section from "./Section";
@@ -19,25 +19,6 @@ import TrackedUsersSection from "./TrackedUsersSection";
1919
import { InfoTooltip } from "../shared/Tooltip";
2020
import type { RepoRef } from "../../services/api";
2121

22-
const SOURCE_LABELS: Record<string, string> = {
23-
lightSearch: "Light Search",
24-
heavyBackfill: "PR Backfill",
25-
forkCheck: "Fork Check",
26-
globalUserSearch: "Tracked User Search",
27-
unfilteredSearch: "Unfiltered Search",
28-
upstreamDiscovery: "Upstream Discovery",
29-
workflowRuns: "Workflow Runs",
30-
hotPRStatus: "Hot PR Status",
31-
hotRunStatus: "Hot Run Status",
32-
notifications: "Notifications",
33-
validateUser: "Validate User",
34-
fetchOrgs: "Fetch Orgs",
35-
fetchRepos: "Fetch Repos",
36-
rateLimitCheck: "Rate Limit Check",
37-
graphql: "GraphQL (other)",
38-
rest: "REST (other)",
39-
};
40-
4122
export default function SettingsPage() {
4223
const navigate = useNavigate();
4324

src/app/components/shared/Tooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function Tooltip(props: TooltipProps) {
6161
{props.children}
6262
</KobalteTooltip.Trigger>
6363
<KobalteTooltip.Portal>
64-
<KobalteTooltip.Content class={TOOLTIP_CONTENT_CLASS + (props.contentClass ? ` ${props.contentClass}` : "")}>
64+
<KobalteTooltip.Content class={`${TOOLTIP_CONTENT_CLASS}${props.contentClass ? ` ${props.contentClass}` : ""}`}>
6565
<KobalteTooltip.Arrow />
6666
{props.content}
6767
</KobalteTooltip.Content>

src/app/services/api-usage.ts

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,36 @@ import { onApiRequest, type ApiRequestInfo } from "./github";
66

77
// ── Types ─────────────────────────────────────────────────────────────────────
88

9-
export type ApiCallSource =
10-
| "lightSearch"
11-
| "heavyBackfill"
12-
| "forkCheck"
13-
| "globalUserSearch"
14-
| "unfilteredSearch"
15-
| "upstreamDiscovery"
16-
| "workflowRuns"
17-
| "hotPRStatus"
18-
| "hotRunStatus"
19-
| "notifications"
20-
| "validateUser"
21-
| "fetchOrgs"
22-
| "fetchRepos"
23-
| "rateLimitCheck"
24-
| "graphql"
25-
| "rest";
26-
27-
export type ApiPool = "graphql" | "core";
9+
const API_CALL_SOURCES = [
10+
"lightSearch", "heavyBackfill", "forkCheck", "globalUserSearch", "unfilteredSearch",
11+
"upstreamDiscovery", "workflowRuns", "hotPRStatus", "hotRunStatus", "notifications",
12+
"validateUser", "fetchOrgs", "fetchRepos", "rateLimitCheck", "graphql", "rest",
13+
] as const;
14+
15+
export type ApiCallSource = (typeof API_CALL_SOURCES)[number];
16+
17+
const API_POOLS = ["graphql", "core"] as const;
18+
19+
export type ApiPool = (typeof API_POOLS)[number];
20+
21+
export const SOURCE_LABELS: Record<ApiCallSource, string> = {
22+
lightSearch: "Light Search",
23+
heavyBackfill: "PR Backfill",
24+
forkCheck: "Fork Check",
25+
globalUserSearch: "Tracked User Search",
26+
unfilteredSearch: "Unfiltered Search",
27+
upstreamDiscovery: "Upstream Discovery",
28+
workflowRuns: "Workflow Runs",
29+
hotPRStatus: "Hot PR Status",
30+
hotRunStatus: "Hot Run Status",
31+
notifications: "Notifications",
32+
validateUser: "Validate User",
33+
fetchOrgs: "Fetch Orgs",
34+
fetchRepos: "Fetch Repos",
35+
rateLimitCheck: "Rate Limit Check",
36+
graphql: "GraphQL (other)",
37+
rest: "REST (other)",
38+
};
2839

2940
export interface ApiCallRecord {
3041
source: ApiCallSource;
@@ -45,8 +56,8 @@ const USAGE_STORAGE_KEY = "github-tracker:api-usage";
4556
// ── Zod schemas ───────────────────────────────────────────────────────────────
4657

4758
const ApiCallRecordSchema = z.object({
48-
source: z.string(),
49-
pool: z.string(),
59+
source: z.enum(API_CALL_SOURCES),
60+
pool: z.enum(API_POOLS),
5061
count: z.number(),
5162
lastCalledAt: z.number(),
5263
});
@@ -64,7 +75,7 @@ export function loadUsageData(): ApiUsageData {
6475
if (raw === null || raw === undefined) return { records: {}, resetAt: null };
6576
const parsed = JSON.parse(raw) as unknown;
6677
const result = ApiUsageDataSchema.safeParse(parsed);
67-
if (result.success) return result.data as ApiUsageData;
78+
if (result.success) return result.data;
6879
return { records: {}, resetAt: null };
6980
} catch {
7081
return { records: {}, resetAt: null };
@@ -104,10 +115,10 @@ export function clearUsageData(): void {
104115

105116
export function checkAndResetIfExpired(): void {
106117
if (_usageData.resetAt !== null && Date.now() > _usageData.resetAt) {
107-
resetUsageData();
118+
_usageData.records = {};
108119
_usageData.resetAt = null;
109-
// Write immediately so the null resetAt persists (prevents redundant re-reset on page reload)
110120
_writeToStorage();
121+
_setVersion((v) => v + 1);
111122
}
112123
}
113124

@@ -195,9 +206,13 @@ const REST_SOURCE_PATTERNS: Array<[RegExp, ApiCallSource]> = [
195206
[/^\/rate_limit/, "rateLimitCheck"],
196207
];
197208

209+
const API_CALL_SOURCE_SET = new Set<string>(API_CALL_SOURCES);
210+
198211
export function deriveSource(info: ApiRequestInfo): ApiCallSource {
199212
if (info.isGraphql) {
200-
return (info.apiSource as ApiCallSource) ?? "graphql";
213+
return info.apiSource && API_CALL_SOURCE_SET.has(info.apiSource)
214+
? (info.apiSource as ApiCallSource)
215+
: "graphql";
201216
}
202217
for (const [pattern, source] of REST_SOURCE_PATTERNS) {
203218
if (pattern.test(info.url)) return source;
@@ -210,5 +225,6 @@ onApiRequest((info) => {
210225
const source = deriveSource(info);
211226
const pool: ApiPool = info.isGraphql ? "graphql" : "core";
212227
trackApiCall(source, pool);
213-
if (info.resetEpochMs !== null && info.isGraphql) updateResetAt(info.resetEpochMs);
228+
// Both pools tracked — Math.max keeps latest; may delay reset of the earlier pool's records
229+
if (info.resetEpochMs !== null) updateResetAt(info.resetEpochMs);
214230
});

src/app/services/github.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export function createGitHubClient(token: string): GitHubOctokitInstance {
151151
// Fire callbacks even on errors — these are real API calls.
152152
// Octokit's RequestError includes response.headers for HTTP errors (403, 404, etc.)
153153
// so we can still extract x-ratelimit-reset when available.
154-
if (status > 0 && _requestCallbacks.length > 0) {
154+
if (status > 0) {
155155
let resetEpochMs: number | null = null;
156156
const errResponse = (err as { response?: { headers?: Record<string, string> } }).response;
157157
const errResetHeader = errResponse?.headers?.["x-ratelimit-reset"];
@@ -164,16 +164,14 @@ export function createGitHubClient(token: string): GitHubOctokitInstance {
164164
throw err;
165165
}
166166

167-
// Success path — fire callbacks and update RL display
168-
if (_requestCallbacks.length > 0) {
169-
const headers = (response.headers ?? {}) as Record<string, string>;
170-
const resetHeader = headers["x-ratelimit-reset"];
171-
const resetEpochMs = resetHeader ? parseInt(resetHeader, 10) * 1000 : null;
172-
const info: ApiRequestInfo = {
173-
url: options.url, method, status, isGraphql, apiSource, resetEpochMs,
174-
};
175-
for (const cb of _requestCallbacks) { try { cb(info); } catch { /* swallow */ } }
176-
}
167+
// Success path — fire callbacks (api-usage.ts registers at module scope) and update RL display
168+
const headers = (response.headers ?? {}) as Record<string, string>;
169+
const resetHeader = headers["x-ratelimit-reset"];
170+
const resetEpochMs = resetHeader ? parseInt(resetHeader, 10) * 1000 : null;
171+
const info: ApiRequestInfo = {
172+
url: options.url, method, status, isGraphql, apiSource, resetEpochMs,
173+
};
174+
for (const cb of _requestCallbacks) { try { cb(info); } catch { /* swallow */ } }
177175

178176
if (response.headers) {
179177
updateRateLimitFromHeaders(response.headers as Record<string, string>);

tests/components/settings/ApiUsageSection.test.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ vi.mock("../../../src/app/services/api-usage", () => ({
3131
getUsageSnapshot: () => mockGetUsageSnapshot(),
3232
getUsageResetAt: () => mockGetUsageResetAt(),
3333
resetUsageData: () => mockResetUsageData(),
34-
// Stubs for module-level side effects in dependent modules
34+
SOURCE_LABELS: {
35+
lightSearch: "Light Search", heavyBackfill: "PR Backfill", forkCheck: "Fork Check",
36+
globalUserSearch: "Tracked User Search", unfilteredSearch: "Unfiltered Search",
37+
upstreamDiscovery: "Upstream Discovery", workflowRuns: "Workflow Runs",
38+
hotPRStatus: "Hot PR Status", hotRunStatus: "Hot Run Status", notifications: "Notifications",
39+
validateUser: "Validate User", fetchOrgs: "Fetch Orgs", fetchRepos: "Fetch Repos",
40+
rateLimitCheck: "Rate Limit Check", graphql: "GraphQL (other)", rest: "REST (other)",
41+
},
3542
trackApiCall: vi.fn(),
3643
updateResetAt: vi.fn(),
3744
checkAndResetIfExpired: vi.fn(),
@@ -273,6 +280,10 @@ describe("ApiUsageSection — reset button", () => {
273280
});
274281

275282
it("calls resetUsageData() when 'Reset counts' button is clicked", () => {
283+
// Wire the mock to clear snapshot on reset, simulating real behavior
284+
mockResetUsageData.mockImplementation(() => {
285+
mockGetUsageSnapshot.mockReturnValue([]);
286+
});
276287
renderSettings();
277288
const btn = screen.getByText("Reset counts");
278289
fireEvent.click(btn);

tests/services/api-usage.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ describe("flushUsageData / loadUsageData", () => {
187187
expect(freshMod.getUsageSnapshot()).toHaveLength(0);
188188
});
189189

190+
it("calls pushNotification when localStorage.setItem throws (quota exceeded)", async () => {
191+
const { pushNotification } = await import("../../src/app/lib/errors");
192+
vi.spyOn(localStorageMock, "setItem").mockImplementationOnce(() => { throw new DOMException("QuotaExceededError"); });
193+
mod.trackApiCall("lightSearch", "graphql");
194+
vi.advanceTimersByTime(600); // fire debounced flush
195+
expect(vi.mocked(pushNotification)).toHaveBeenCalledWith(
196+
"localStorage:api-usage",
197+
expect.stringContaining("write failed"),
198+
"warning"
199+
);
200+
});
201+
190202
it("loadUsageData restores state on module init from valid localStorage", async () => {
191203
const storedData = {
192204
records: {
@@ -342,6 +354,29 @@ describe("checkAndResetIfExpired", () => {
342354
const parsed = JSON.parse(raw!);
343355
expect(parsed.resetAt).toBeNull();
344356
});
357+
358+
it("clears records and nulls resetAt atomically on expiry", () => {
359+
vi.setSystemTime(new Date("2026-01-01T12:00:00Z"));
360+
mod.trackApiCall("lightSearch", "graphql");
361+
mod.updateResetAt(new Date("2026-01-01T11:00:00Z").getTime());
362+
// Flush pending writes so state is settled
363+
vi.advanceTimersByTime(600);
364+
365+
// Record localStorage state before the expiry check
366+
const before = localStorageMock.getItem("github-tracker:api-usage");
367+
const beforeParsed = JSON.parse(before!);
368+
expect(beforeParsed.resetAt).not.toBeNull();
369+
expect(Object.keys(beforeParsed.records)).toHaveLength(1);
370+
371+
vi.setSystemTime(new Date("2026-01-01T12:01:00Z"));
372+
mod.checkAndResetIfExpired();
373+
374+
// After expiry: records cleared, resetAt null — written in a single pass
375+
const after = localStorageMock.getItem("github-tracker:api-usage");
376+
const afterParsed = JSON.parse(after!);
377+
expect(afterParsed.resetAt).toBeNull();
378+
expect(Object.keys(afterParsed.records)).toHaveLength(0);
379+
});
345380
});
346381

347382
describe("updateResetAt", () => {
@@ -369,6 +404,17 @@ describe("updateResetAt", () => {
369404
mod.updateResetAt(5000);
370405
expect(mod.getUsageResetAt()).toBe(9000);
371406
});
407+
408+
it.each([0, -1, NaN, Infinity, -Infinity])("rejects invalid resetAt: %s", (val) => {
409+
mod.updateResetAt(val);
410+
expect(mod.getUsageResetAt()).toBeNull();
411+
});
412+
413+
it("does not overwrite valid resetAt with invalid value", () => {
414+
mod.updateResetAt(5000);
415+
mod.updateResetAt(NaN);
416+
expect(mod.getUsageResetAt()).toBe(5000);
417+
});
372418
});
373419

374420
// ── deriveSource ─────────────────────────────────────────────────────────────
@@ -423,4 +469,8 @@ describe("deriveSource — URL pattern matching", () => {
423469
it("hotRunStatus pattern takes priority over workflowRuns (specific before general)", () => {
424470
expect(mod.deriveSource(makeInfo({ url: "/repos/foo/bar/actions/runs/999" }))).toBe("hotRunStatus");
425471
});
472+
473+
it("falls back to 'graphql' for unrecognized apiSource string", () => {
474+
expect(mod.deriveSource(makeInfo({ isGraphql: true, url: "/graphql", apiSource: "unknownLabel" }))).toBe("graphql");
475+
});
426476
});

tests/services/github-rate-limit.test.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -61,57 +61,65 @@ describe("fetchRateLimitDetails — success response", () => {
6161
expect(result!.graphql.resetAt).toBeInstanceOf(Date);
6262
});
6363

64-
it("does not cache failures — returns null on error response without caching", async () => {
65-
// First call with error — should return null
66-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeSuccessResponse())); // seed cache
67-
await fetchRateLimitDetails();
68-
69-
// Now force a network error: since result is cached (within 5s), it returns cached
70-
// We cannot easily test failure with caching without fake timers.
71-
// Instead, test that the function handles errors gracefully at all.
72-
// This is tested via: mock a response that results in no graphql key
64+
it("returns null when response body is missing the graphql key", async () => {
65+
// Use fresh module to bypass staleness cache
66+
vi.resetModules();
67+
vi.doMock("../../src/app/stores/auth", () => ({
68+
token: () => "fake-token-for-rate-limit-tests",
69+
onAuthCleared: vi.fn(),
70+
}));
7371
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(
7472
new Response(
7573
JSON.stringify({ resources: { core: { limit: 5000, remaining: 4800, reset: 99999, used: 0 } } }),
7674
{ status: 200, headers: { "content-type": "application/json" } }
7775
)
7876
));
7977

80-
// The cache from the prior test means this won't re-fetch within 5s
81-
// That's expected behavior — just verify cached result is returned
82-
const result = await fetchRateLimitDetails();
83-
// Either the cache hit (from prior test within 5s) or a new fetch succeeded
84-
// In both cases, result should not be null
85-
expect(result).not.toBeNull();
78+
const { fetchRateLimitDetails: freshFetch } = await import("../../src/app/services/github");
79+
const result = await freshFetch();
80+
expect(result).toBeNull();
8681
});
8782
});
8883

8984
describe("fetchRateLimitDetails — staleness cache", () => {
90-
afterEach(() => {
91-
vi.unstubAllGlobals();
92-
});
93-
9485
it("returns the same data for two calls within 5 seconds (cache hit)", async () => {
86+
// Fresh module for hermetic test — no cross-test cache dependency
87+
vi.resetModules();
88+
vi.doMock("../../src/app/stores/auth", () => ({
89+
token: () => "fake-token-for-staleness-test",
90+
onAuthCleared: vi.fn(),
91+
}));
9592
const mockFetch = vi.fn().mockResolvedValue(makeSuccessResponse());
9693
vi.stubGlobal("fetch", mockFetch);
9794

98-
// First call — may hit cache from prior test or hit network
99-
const result1 = await fetchRateLimitDetails();
95+
const { fetchRateLimitDetails: freshFetch } = await import("../../src/app/services/github");
96+
97+
const result1 = await freshFetch();
10098
expect(result1).not.toBeNull();
101-
const callsAfterFirst = mockFetch.mock.calls.length;
99+
expect(mockFetch).toHaveBeenCalledTimes(1);
102100

103101
// Second call immediately — must not make a new network request
104-
const result2 = await fetchRateLimitDetails();
102+
const result2 = await freshFetch();
105103
expect(result2).not.toBeNull();
106-
expect(mockFetch.mock.calls.length).toBe(callsAfterFirst); // no extra calls
107-
expect(result2!.core.remaining).toBe(result1!.core.remaining); // same data
104+
expect(mockFetch).toHaveBeenCalledTimes(1); // no extra calls
105+
expect(result2!.core.remaining).toBe(result1!.core.remaining);
106+
107+
vi.unstubAllGlobals();
108108
});
109109
});
110110

111111
describe("fetchRateLimitDetails — null client", () => {
112-
// Cannot reliably test null-client path due to module-level staleness cache.
113-
// getClient() is only called when cache is expired (>5s), and vi.resetModules()
114-
// + dynamic import is needed for clean state — but that conflicts with the auth
115-
// module's eager client creation. Documented as a known test limitation.
116-
it.todo("returns null when getClient() returns null");
112+
it("returns null when getClient() returns null", async () => {
113+
// Use vi.resetModules() for clean module state (clears staleness cache).
114+
// Mock auth to return null token so no eager client is created.
115+
vi.resetModules();
116+
vi.doMock("../../src/app/stores/auth", () => ({
117+
token: () => null,
118+
onAuthCleared: vi.fn(),
119+
}));
120+
121+
const { fetchRateLimitDetails: freshFetch } = await import("../../src/app/services/github");
122+
const result = await freshFetch();
123+
expect(result).toBeNull();
124+
});
117125
});

0 commit comments

Comments
 (0)