-
-
Notifications
You must be signed in to change notification settings - Fork 123
fix: prevent quota refresh from falsely resetting to 5000 #299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a7f0dac
7fc829f
c977428
ca0d48e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import { act, render, screen } from '@testing-library/react' | ||
| import { useState } from 'react' | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { AppProvider, useApp } from './AppContext' | ||
|
|
||
| const { loadAnalysis, saveAnalysis, fetchRateLimit, bumpPatGeneration } = vi.hoisted(() => ({ | ||
| loadAnalysis: vi.fn(), | ||
| saveAnalysis: vi.fn(), | ||
| fetchRateLimit: vi.fn(), | ||
| bumpPatGeneration: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('../services/cache', () => ({ loadAnalysis, saveAnalysis })) | ||
| vi.mock('../services/github', async importOriginal => { | ||
| const actual = await importOriginal() | ||
| return { | ||
| ...actual, | ||
| fetchOrg: vi.fn(), | ||
| fetchRepos: vi.fn(), | ||
| fetchContributors: vi.fn(), | ||
| fetchIssues: vi.fn(), | ||
| fetchPulls: vi.fn(), | ||
| fetchRateLimit, | ||
| bumpPatGeneration, | ||
| } | ||
| }) | ||
|
|
||
| function RateLimitProbe() { | ||
| const { rateLimit, savePat, refreshRateLimit } = useApp() | ||
| const [refreshResult, setRefreshResult] = useState('') | ||
| return ( | ||
| <> | ||
| <output data-testid="rate-limit">{JSON.stringify(rateLimit)}</output> | ||
| <output data-testid="refresh-result">{refreshResult}</output> | ||
| <button onClick={() => savePat('new-token')}>Save PAT</button> | ||
| <button onClick={() => refreshRateLimit().then(result => setRefreshResult(String(result)))}> | ||
| Refresh quota | ||
| </button> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| function dispatchRateLimit(detail) { | ||
| act(() => { | ||
| window.dispatchEvent(new CustomEvent('rate-limit-update', { detail })) | ||
| }) | ||
| } | ||
|
|
||
| describe('AppContext rate-limit events', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers() | ||
| vi.setSystemTime(new Date('2026-10-05T12:00:00Z')) | ||
| localStorage.clear() | ||
| loadAnalysis.mockResolvedValue(null) | ||
| saveAnalysis.mockReset() | ||
| fetchRateLimit.mockReset() | ||
| bumpPatGeneration.mockReset() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| localStorage.clear() | ||
| }) | ||
|
|
||
| it('rejects same-window increases but accepts decreases and next-window updates', () => { | ||
| const reset = Math.floor(Date.now() / 1000) + 60 | ||
| const current = { limit: 5000, remaining: 4200, used: 800, reset } | ||
| localStorage.setItem('oe_rate_limit', JSON.stringify(current)) | ||
|
|
||
| render( | ||
| <AppProvider> | ||
| <RateLimitProbe /> | ||
| </AppProvider> | ||
| ) | ||
|
|
||
| dispatchRateLimit({ ...current, remaining: 5000, used: 0 }) | ||
| expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current)) | ||
| expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current) | ||
|
|
||
| const decreased = { ...current, remaining: 4199, used: 801 } | ||
| dispatchRateLimit(decreased) | ||
| expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(decreased)) | ||
|
|
||
| vi.setSystemTime(new Date((reset + 1) * 1000)) | ||
| const nextWindow = { ...current, remaining: 5000, used: 0, reset: reset + 3600 } | ||
| dispatchRateLimit(nextWindow) | ||
| expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(nextWindow)) | ||
| }) | ||
|
|
||
| it('ignores invalid rate-limit events', () => { | ||
| const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 } | ||
| localStorage.setItem('oe_rate_limit', JSON.stringify(current)) | ||
|
|
||
| render( | ||
| <AppProvider> | ||
| <RateLimitProbe /> | ||
| </AppProvider> | ||
| ) | ||
|
|
||
| dispatchRateLimit({ ...current, limit: 'invalid' }) | ||
|
|
||
| expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current)) | ||
| expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current) | ||
| }) | ||
|
|
||
| it('clears the displayed and stored quota when a PAT is saved', () => { | ||
| const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 } | ||
| localStorage.setItem('oe_pat', 'old-token') | ||
| localStorage.setItem('oe_rate_limit', JSON.stringify(current)) | ||
|
|
||
| render( | ||
| <AppProvider> | ||
| <RateLimitProbe /> | ||
| </AppProvider> | ||
| ) | ||
|
|
||
| act(() => screen.getByRole('button', { name: 'Save PAT' }).click()) | ||
|
|
||
| expect(screen.getByTestId('rate-limit').textContent).toBe('null') | ||
| expect(localStorage.getItem('oe_pat')).toBe('new-token') | ||
| expect(localStorage.getItem('oe_rate_limit')).toBeNull() | ||
| expect(bumpPatGeneration).toHaveBeenCalledOnce() | ||
| }) | ||
|
|
||
| it('marks a refresh as superseded when the stored PAT changes mid-request', async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,165p' src/context/AppContext.test.jsxRepository: AOSSIE-Org/OrgExplorer Length of output: 5061 🏁 Script executed: git diff --unified=5 87d3ebdca6e7b9e27149b95a9cebf9c2032bf3d1 ca0d48ee2af0a1137b04c322afcc0bf69762156e -- src/context/AppContext.jsx src/context/AppContext.js src/context/AppContext.test.jsx; printf '\n--- AppContext file candidates ---\n'; rg --files src/context | rg 'AppContext'; printf '\n--- rate-limit bindings and tests ---\n'; rg -n -F -- 'rate-limit-update' src --glob '!**/node_modules/**'; rg -n -F -- 'refreshRateLimit' src --glob '!**/node_modules/**'Repository: AOSSIE-Org/OrgExplorer Length of output: 11030 🏁 Script executed: nl -ba src/context/AppContext.jsx | sed -n '95,165p'; printf '\n--- github quota emitter and validator ---\n'; nl -ba src/services/github.js | sed -n '95,165p'; printf '\n--- tracked quota refresh callers ---\n'; nl -ba src/pages/SettingsPage.jsx | sed -n '255,285p'; printf '\n--- related tests ---\n'; rg -n --glob '*.{test,spec}.{js,jsx,ts,tsx}' 'rate-limit|refreshRateLimit|fetchRateLimit' src || test "$?" -eq 1Repository: AOSSIE-Org/OrgExplorer Length of output: 9408 Test a live event during a pending quota refresh. The tests cover live events and refreshes separately. None dispatches a lower live count while This is a meaningful regression-coverage gap, not a current functional failure. A cosmetic-only classification understates its value. 🤖 Prompt for AI Agents |
||
| let resolveRefresh | ||
| localStorage.setItem('oe_pat', 'old-token') | ||
| fetchRateLimit.mockImplementation( | ||
| () => new Promise(resolve => { resolveRefresh = resolve }) | ||
| ) | ||
|
|
||
| render( | ||
| <AppProvider> | ||
| <RateLimitProbe /> | ||
| </AppProvider> | ||
| ) | ||
|
|
||
| act(() => screen.getByRole('button', { name: 'Refresh quota' }).click()) | ||
| localStorage.setItem('oe_pat', 'new-token') | ||
| await act(async () => { | ||
| resolveRefresh({ limit: 5000, remaining: 4000, used: 1000, reset: 1_800_000_000 }) | ||
| await Promise.resolve() | ||
| }) | ||
|
|
||
| expect(screen.getByTestId('refresh-result').textContent).toBe('superseded') | ||
| expect(localStorage.getItem('oe_rate_limit')).toBeNull() | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Accept authoritative live-header increases.
If a later GitHub response reports more remaining requests in the same window, this guard discards its
rate-limit-updateevent. GitHub documents that regional processing can produce this result and says to rely on response headers when they disagree with/rate_limit. Apply the stale-increase guard to refresh results, not to live-header events. (docs.github.com)🤖 Prompt for AI Agents