@@ -7,6 +7,9 @@ vi.mock('../../../lib/github', () => ({
77
88import { fetchGitHubContributions } from '../../../lib/github' ;
99import type { ContributionCalendar } from '../../../types' ;
10+ import { quotaMonitor } from '@/services/github/quota-monitor' ;
11+ import { refreshPolicy } from '@/services/github/refresh-policy' ;
12+ import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter' ;
1013
1114// Calendar with a known, predictable streak so assertions are deterministic.
1215// Last day (2024-06-16) has commits; "today" in tests is set to that date.
@@ -27,17 +30,25 @@ const mockCalendar: ContributionCalendar = {
2730 ] ,
2831} ;
2932
30- function makeRequest ( params : Record < string , string > = { } ) : Request {
33+ function makeRequest (
34+ params : Record < string , string > = { } ,
35+ headers : Record < string , string > = { }
36+ ) : Request {
3137 const url = new URL ( 'http://localhost/api/stats' ) ;
3238 for ( const [ key , value ] of Object . entries ( params ) ) {
3339 url . searchParams . set ( key , value ) ;
3440 }
35- return new Request ( url . toString ( ) ) ;
41+ return new Request ( url . toString ( ) , {
42+ headers : new Headers ( headers ) ,
43+ } ) ;
3644}
3745
3846describe ( 'GET /api/stats' , ( ) => {
3947 beforeEach ( ( ) => {
4048 vi . clearAllMocks ( ) ;
49+ quotaMonitor . reset ( ) ;
50+ refreshPolicy . reset ( ) ;
51+ refreshRateLimiter . reset ( ) ;
4152 vi . mocked ( fetchGitHubContributions ) . mockResolvedValue ( {
4253 calendar : mockCalendar ,
4354 repoContributions : [ ] ,
@@ -110,6 +121,7 @@ describe('GET /api/stats', () => {
110121 expect ( response . headers . get ( 'Cache-Control' ) ) . toBe ( 'no-store, no-cache, must-revalidate' ) ;
111122 expect ( response . headers . get ( 'Pragma' ) ) . toBe ( 'no-cache' ) ;
112123 expect ( response . headers . get ( 'Expires' ) ) . toBe ( '0' ) ;
124+ expect ( response . headers . get ( 'X-Refresh-Status' ) ) . toBe ( 'Fresh' ) ;
113125 } ) ;
114126
115127 it ( 'still returns valid stats data when refresh=true' , async ( ) => {
@@ -143,6 +155,47 @@ describe('GET /api/stats', () => {
143155 expect ( fetchGitHubContributions ) . toHaveBeenCalledWith ( 'testuser' , { bypassCache : false } ) ;
144156 } ) ;
145157
158+ it ( 'serves cached stats instead of bypassing cache during refresh cooldown' , async ( ) => {
159+ await GET ( makeRequest ( { user : 'testuser' , refresh : 'true' } ) ) ;
160+
161+ const response = await GET ( makeRequest ( { user : 'testuser' , refresh : 'true' } ) ) ;
162+
163+ expect ( response . status ) . toBe ( 200 ) ;
164+ expect ( fetchGitHubContributions ) . toHaveBeenLastCalledWith ( 'testuser' , {
165+ bypassCache : false ,
166+ } ) ;
167+ expect ( response . headers . get ( 'X-Refresh-Status' ) ) . toBe ( 'Cooldown-Served-Cached' ) ;
168+ expect ( response . headers . get ( 'Cache-Control' ) ) . toBe (
169+ 'public, s-maxage=3600, stale-while-revalidate=86400'
170+ ) ;
171+ } ) ;
172+
173+ it ( 'returns 429 when stats refresh exceeds the client refresh rate limit' , async ( ) => {
174+ refreshRateLimiter . setLimit ( 1 ) ;
175+
176+ await GET ( makeRequest ( { user : 'testuser' , refresh : 'true' } , { 'x-real-ip' : '203.0.113.9' } ) ) ;
177+
178+ const response = await GET (
179+ makeRequest ( { user : 'octocat' , refresh : 'true' } , { 'x-real-ip' : '203.0.113.9' } )
180+ ) ;
181+
182+ expect ( response . status ) . toBe ( 429 ) ;
183+ const body = await response . json ( ) ;
184+ expect ( body . error ) . toContain ( 'Refresh rate limit exceeded' ) ;
185+ expect ( response . headers . get ( 'X-RateLimit-Limit' ) ) . toBe ( '1' ) ;
186+ } ) ;
187+
188+ it ( 'blocks stats refresh when the shared GitHub quota is low' , async ( ) => {
189+ quotaMonitor . setQuota ( 5000 , 400 , Date . now ( ) + 60_000 ) ;
190+
191+ const response = await GET ( makeRequest ( { user : 'testuser' , refresh : 'true' } ) ) ;
192+
193+ expect ( response . status ) . toBe ( 429 ) ;
194+ const body = await response . json ( ) ;
195+ expect ( body . error ) . toContain ( 'quota is low' ) ;
196+ expect ( fetchGitHubContributions ) . not . toHaveBeenCalled ( ) ;
197+ } ) ;
198+
146199 it ( 'accepts a valid IANA timezone without error' , async ( ) => {
147200 const response = await GET ( makeRequest ( { user : 'testuser' , tz : 'America/New_York' } ) ) ;
148201 expect ( response . status ) . toBe ( 200 ) ;
0 commit comments